/* ==========================================================================
   Serifo — Page sections
   Structural rearrangement (nav pattern, grid column-count, 2-col -> stack)
   happens at the single --mobile-bp breakpoint below, matching genuinely
   different component arrangements in the Figma Mobile frame. All sizing
   within each regime (type, spacing, component dimensions) is fluid via
   tokens.css clamp() variables between 430px and 1440px.
   ========================================================================== */

:root { --mobile-bp: 767px; }

/* ---------------------------------------------------------------------- */
/* Header / Nav                                                            */
/* Sticky/fixed to the viewport at all times (not just over the hero).     */
/* Starts transparent over the hero, then transitions to a solid white bar */
/* once scrolled past it — toggled via .site-header--solid (added by       */
/* js/main.js on scroll). Falls back to plain white if JS is unavailable,   */
/* since a transparent bar over body content is unreadable.                */
/* ---------------------------------------------------------------------- */
.site-header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 50;
  width: 100%;
  /* A 10%-black fill over a blur, so the bar reads as frosted glass sitting
     on the hero — the line graphic stays visible through it but softened,
     which separates the nav text from the curves running behind it. */
  background: rgba(0, 0, 0, 0.1);
  backdrop-filter: blur(5.2px);
  border-bottom: 0.5px solid var(--color-bone);
  transition: background-color 0.25s ease, backdrop-filter 0.25s ease, border-color 0.25s ease, transform 0.25s ease, opacity 0.25s ease;
}

/* Hidden once the footer scrolls into view (toggled by js/main.js via
   IntersectionObserver) — the fixed header would otherwise float over the
   footer's own nav links for the rest of the scroll, duplicating them. */
.site-header--hidden {
  transform: translateY(-100%);
  opacity: 0;
  pointer-events: none;
}

/* Solid white once past the hero: the blur is dropped rather than left
   running behind an opaque fill, where it would cost a compositing layer
   for something nobody can see. */
.no-js .site-header,
.site-header--solid {
  background: var(--color-white);
  backdrop-filter: none;
  border-bottom-color: rgba(85, 0, 0, 0.15);
}

.site-header__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-block: var(--gap-xxs);
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--gap-xxs);
}

.brand__logo { width: var(--logo-w); height: var(--logo-h); }
.brand__logo circle { fill: var(--color-bone); transition: fill 0.25s ease; }
.no-js .brand__logo circle,
.site-header--solid .brand__logo circle { fill: var(--color-maroon); }

.brand__name {
  font-family: var(--font-serif);
  font-weight: 700;
  /* Fluid like the rest of the scale rather than a flat 24px, anchored the
     same way the type tokens are: 20px at 320, the Figma 24px from 1440. */
  font-size: clamp(1.25rem, 1.1786rem + 0.3571vw, 1.5rem);
  color: var(--color-bone);
  transition: color 0.25s ease;
}

.no-js .brand__name,
.site-header--solid .brand__name { color: var(--color-maroon); }

.site-nav {
  display: flex;
  align-items: center;
}
@media (min-width: 768px) {
  .site-nav {
    flex: 1;
    margin-left: clamp(2rem, 1.5rem + 1.5vw, 3.5rem);
  }
}

.site-nav__links {
  display: flex;
  align-items: center;
  gap: var(--nav-links-gap);
  list-style: none;
  margin: 0;
  padding: 0;
}
@media (min-width: 768px) {
  .site-nav__links {
    width: 100%;
    gap: clamp(1.25rem, 2vw, var(--nav-links-gap));
  }
}

.site-nav__links a:not(.btn) {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: var(--fs-caption);
  letter-spacing: 0;
  color: var(--color-bone);
  text-transform: uppercase;
  white-space: nowrap;
  transition: color 0.25s ease;
}

.no-js .site-nav__links a:not(.btn),
.site-header--solid .site-nav__links a:not(.btn) { color: var(--color-maroon); }

.site-nav__actions,
.site-nav__links-cta { display: flex; }
@media (min-width: 768px) {
  .site-nav__actions,
  .site-nav__links-cta {
    list-style: none;
    margin-left: auto;
    align-items: center;
    gap: var(--gap-xxs);
  }
}

/* Nav CTA reverses with the header: bone-on-maroon over the hero,
   maroon-on-bone once the header goes solid white (otherwise it'd render
   as a near-invisible white button on a white bar). */
.no-js .site-nav__actions .btn--light,
.site-header--solid .site-nav__actions .btn--light,
.no-js .site-nav__links-cta .btn--light,
.site-header--solid .site-nav__links-cta .btn--light {
  background: var(--color-maroon);
  color: var(--color-bone);
}

.no-js .site-nav__actions .btn--outline,
.site-header--solid .site-nav__actions .btn--outline {
  border-color: var(--color-maroon);
  color: var(--color-maroon);
}
.no-js .site-nav__actions .btn--outline:hover,
.site-header--solid .site-nav__actions .btn--outline:hover {
  background: rgba(85, 0, 0, 0.08);
}
.no-js .site-nav__actions .btn--outline:active,
.site-header--solid .site-nav__actions .btn--outline:active {
  background: rgba(85, 0, 0, 0.15);
}

.nav-toggle {
  display: none;
  background: var(--color-bone);
  border: none;
  width: 38px;
  height: 38px;
  align-items: center;
  justify-content: center;
}

.nav-toggle__bars { display: flex; flex-direction: column; gap: 6px; }
.nav-toggle__bars span { width: 18px; height: 2px; background: var(--color-black); display: block; }

@media (max-width: 767px) {
  .site-nav__links { display: none; }
  .nav-toggle { display: inline-flex; }

  .site-nav__links.is-open {
    display: flex;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    /* Full-screen panel. The 100% inside the calc resolves against the
       same containing block top:100% does — the fixed header's own
       padding box — so bar height + panel height always lands exactly on
       the viewport bottom, without hardcoding a bar height (--header-h
       is the hero's spacing token and runs several px taller than the
       bar actually measures on mobile). dvh so the panel still ends at
       the true bottom edge while mobile browser chrome is showing;
       the vh line above it is the fallback for browsers without dvh. */
    height: calc(100vh - 100%);
    height: calc(100dvh - 100%);
    overflow-y: auto;
    flex-direction: column;
    align-items: flex-start;
    gap: 0;
    background: var(--color-maroon);
    border-top: 0.5px solid var(--color-bone);
  }
  .site-nav__links.is-open li { width: 100%; }
  /* Now that the panel is full-height, the CTAs anchor to its bottom
     instead of trailing the last link. auto margin rather than
     justify-content on the panel, so only this item gets pushed down —
     the links above it stay stacked tight against the bar. It collapses
     harmlessly back to a normal flow item if the panel ever runs out of
     spare height (short landscape viewports), where the panel's own
     overflow-y takes over. */
  .site-nav__links.is-open .site-nav__actions,
  .site-nav__links.is-open .site-nav__links-cta {
    margin-top: auto;
    display: flex;
    flex-direction: column;
    gap: var(--gap-xs);
    width: 100%;
    padding: var(--gap-sm) var(--container-pad);
  }
  .site-nav__links.is-open a:not(.btn) {
    display: block;
    width: 100%;
    padding: var(--gap-sm) var(--container-pad);
    border-bottom: 0.25px solid rgba(248, 247, 245, 0.3);
  }
  .site-nav__links.is-open .btn {
    display: flex;
    width: 100%;
    margin: 0;
  }

  /* The open panel is maroon in every header state, so its contents have
     to stay bone/reversed even once the bar itself has gone solid white.
     Without this, .site-header--solid (scrolled pages), its static twin
     .site-header--static-solid (careers/privacy/cookies — pages.css) and
     .no-js all repaint these links maroon, i.e. maroon-on-maroon: the
     menu opens looking like an empty block. Scoped through .site-header
     purely for specificity — pages.css loads after this file, so an
     equal-specificity rule here would lose the tie to --static-solid. */
  .site-header .site-nav__links.is-open a:not(.btn) { color: var(--color-bone); }
  .site-header .site-nav__links.is-open .btn--light {
    background: var(--color-bone);
    color: var(--color-maroon);
    border: none;
  }
  .site-header .site-nav__links.is-open .btn--outline {
    background: transparent;
    color: var(--color-bone);
    border: 1px solid var(--color-bone);
  }
  .site-header .site-nav__links.is-open .btn--outline:hover {
    background: rgba(248, 247, 245, 0.12);
  }

  /* Frost the bar while the panel is open. In its transparent state the bar
     is only a 10%-black tint, so over the hero the line graphic reads
     straight through it — against the solid panel hanging directly below,
     those curves crossing the top strip look like a rendering glitch rather
     than a design. A maroon veil plus blur ties the bar to the panel and
     settles the lines behind it.

     Scoped to the transparent state on purpose: the solid-white pages
     (careers, the articles, the policies) already read as one piece with
     the panel and should keep their white bar. */
  body.has-nav-open .site-header:not(.site-header--solid):not(.site-header--static-solid) {
    background: rgba(85, 0, 0, 0.82);
    backdrop-filter: blur(12px);
  }
}

/* ---------------------------------------------------------------------- */
/* Hero                                                                     */
/* ---------------------------------------------------------------------- */
.hero {
  position: relative;
  /* min-height, not height: on a viewport too short for the composition the
     section grows rather than forcing its contents to overlap. Belt and
     braces with the padding ceiling on .hero__inner — that keeps the copy
     inside one screen at any realistic height, this guarantees no collision
     even if the copy itself grows. */
  min-height: 100vh;
  background: var(--color-maroon);
  color: var(--color-bone);
  overflow: clip;
  /* Column so the quicklinks bar can sit at the bottom as a real sibling
     and .hero__inner simply take what's left. The bar used to be absolutely
     positioned, which meant .hero__inner needed a bottom padding
     hand-matched to its height — a number that went stale the moment the
     copy rewrapped. The line background and wash are absolute, so they sit
     outside this flow and still cover the section. */
  display: flex;
  flex-direction: column;
}

/* ---- Converging line background (replaces the old video + scrim) ------
   Two fans of curves meeting at one point, drawn in once on load with a
   glow blooming where they meet. Desktop and mobile are separate
   compositions swapped here, not one scaled — see the markup note in
   index.html. Both are preserveAspectRatio="slice", so they cover the
   section and crop at whichever axis has spare. */
/* contain:paint bounds the animation's repaints to this box. The loop
   animates stroke-dashoffset, which is a PAINT property — it cannot be
   handed to the compositor the way transform/opacity can, so every frame
   re-rasterizes all 68 stroked paths and their gradient paint servers.
   Without containment the browser has to treat that as potentially
   invalidating a wider area of the page each time.

   No will-change here on purpose. It was tried and removed: promoting this
   to its own compositing layer means a full-screen texture that has to be
   re-uploaded to the GPU every frame its paint changes, which for
   paint-animated content costs more than it saves. Containment is the part
   that actually helps. The per-frame cost itself is dealt with by not
   repainting forever — see the one-shot block for touch devices below. */
/* One viewport tall, pinned to the top — NOT the full height of .hero.

   The graphic is a one-screen composition: the copy is placed at 42vh so it
   hangs just under the point where the lines meet, and that only works if
   the meeting point is fixed relative to the viewport. It was inset:0, so
   the SVG filled the whole section instead. That is identical while the
   hero is exactly 100vh, but .hero has min-height (so a viewport too short
   for the copy makes the section grow rather than overlap), and when it
   grew the graphic grew with it: preserveAspectRatio="slice" scales to
   cover, so a taller box pushed the convergence point DOWN the screen while
   the copy stayed at 42vh of the viewport. Below ~690px tall the point
   crossed the headline and the dot appeared inside the type.

   Height-capping it decouples the two. The convergence now lands at
   min(50vh - 6.215vw, 40.387vh) at every window, which is below 42vh for
   any window wider than it is tall, so the copy always clears it.

   On a window short enough for the section to grow, the strip below the
   fold has no lines behind it. That strip is where the quicklinks bar sits
   and the lines there are at their widest and faintest, so it reads as the
   fan running off the bottom of the screen rather than as a cut. */
.hero__lines {
  position: absolute;
  inset: 0 0 auto;
  height: 100vh;
  pointer-events: none;
  contain: paint;
}

/* Exactly the section's width. Drawing it wider does NOT spread the fan:
   preserveAspectRatio="slice" scales to cover, so a wider box just scales
   everything up, and since the box is centred over a fixed-width section
   the visible window then falls on the middle of the viewBox — cropping
   off the outermost, widest lines and leaving the dense bundle near the
   axis magnified. It reads as less spread, not more. How far the fan opens
   is set in the geometry instead; see the scale transform on the fan
   groups in index.html. */
.hero__lines-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.hero__lines-svg--mobile { display: none; }
@media (max-width: 767px) {
  .hero__lines-svg--desktop { display: none; }
  .hero__lines-svg--mobile { display: block; }
}

/* The draw-in. Every path is pathLength="1" with stroke-dasharray="1 1",
   i.e. normalised so one dash plus one gap spans the whole curve whatever
   its true length. Offsetting by a full 1 therefore parks the gap over the
   line (invisible) and animating to 0 slides the dash across it, drawing
   it on. Because pathLength normalises them, every line draws in the same
   time regardless of how long it actually is — the short inner curves and
   the long outer sweeps arrive together, which is what makes it read as
   converging rather than as separate strokes racing.

   --i is the line's index within its own fan, set inline in the markup,
   available to stagger them; see --hero-line-stagger below for why it is
   currently 0.

   The whole thing loops on a --hero-cycle timeline, with the wait built
   into the keyframes rather than added around them — CSS has no
   between-iteration delay (animation-delay applies to the first run only),
   so it has to be a stretch of the cycle where nothing changes:

     0-24%    draw in            1.2s
     24-96%   hold, fully drawn  3.6s   <- the gap between draws
     96-100%  fade out (ease-in) 0.2s
                                        <- no blank phase at all

   The fade only dims; it never moves anything. An earlier version cleared
   by animating stroke-dashoffset back, which unwound each curve toward the
   centre and read as the fan collapsing. Holding the offset and dropping
   opacity instead means the picture disappears exactly where it stands.

   The wait is the HOLD, not an empty stretch: the finished fan is on screen
   2.9s of every 5, and blank for 0.6s. An early version had that backwards
   and left the hero bare maroon for 1.4s a cycle, which read as broken.

   Change --hero-cycle to retime the whole sequence proportionally; the
   percentages keep their relationship to each other. */
.hero {
  --hero-cycle: 20s;

  /* One unit the whole hero composition is measured in, and the reason it
     scales properly on a short screen.

     Everything here used to be sized off width alone (vw), so a 1157x742
     window got 80% of the design width but nearly full-size type, because
     the clamps floor out long before the window does. The composition then
     needed more height than the window had, and the layout resolved that by
     pushing the headline up into the convergence point. Height was never
     part of the calculation.

     min(6.944vw, 11.111vh) resolves to exactly 100px at the 1440x900 design
     frame — 1440 x 0.06944 and 900 x 0.11111 — and to whichever axis is
     tighter anywhere else. So the hero behaves like a poster: it scales as a
     whole, keeping its proportions, instead of each value shrinking at its
     own rate along one axis.

     The clamp is for legibility, not layout. Below 75px the headline would
     drop under ~47px, and above 126px it would pass ~78px, the 4K figure in
     the type spec. Inside those bounds the hero is free to scale. */
  --hero-u: clamp(75px, min(6.944vw, 11.111vh), 126px);
}

/* Resting state: fully drawn. This is what shows before JS starts the loop,
   what no-JS visitors get, and what reduced motion falls back to — never a
   blank hero. */
.hero-line { stroke-dashoffset: 0; }
.hero-glow-aura,
.hero-glow-dot {
  transform-box: fill-box;
  transform-origin: center;
}

/* .is-replaying is added by js/main.js whenever the hero is on screen and
   removed when it scrolls away, so the sequence restarts on return rather
   than looping unseen. */

.hero.is-replaying .hero-line {
  animation: hero-line-cycle var(--hero-cycle) linear infinite;
  /* Stagger is off (0s), and that is what makes the clear instant.
     animation-delay shifts a line's WHOLE timeline, not just its start, so
     any stagger also staggers the cut at 88% — and since --i:0 is the
     outermost line, the fan cleared from the outside in and read as
     collapsing toward the centre rather than disappearing. At 0s every
     line shares one timeline: they draw together and vanish on the same
     frame.

     --i is still set per line in the markup, so raising this brings the
     edge-inward sweep back on the draw — at the cost of the same spread on
     the clear. */
  animation-delay: calc(var(--i, 0) * var(--hero-line-stagger, 0s));
}

@keyframes hero-line-cycle {
  /* cubic-bezier(0.65, 0, 0.35, 1) — ease-in-out, not the ease-out-quint
     this used to carry. That curve reached 96% of the distance in the
     first half of the draw and then crept the last 4% for the remaining
     0.75s, so the fans hung visibly close but not touching for half the
     animation and read as failing to merge. This one holds a steadier
     pace and closes the final 5% in 0.32s, so contact actually lands. */
  0%   { stroke-dashoffset: 1; opacity: 1; animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1); }
  24%  { stroke-dashoffset: 0; opacity: 1; }
  /* Fades out over 96-100% (0.2s) with the offset held at 0, so the line
     dims where it stands instead of unwinding — the collapse came from
     animating the offset itself.

     ease-in, not linear: a linear fade sits at half opacity for half the
     window, and that washed-out ghost of the whole fan is the most visible
     part of the exit. Easing in holds the line near full strength and then
     drops it away quickly, so it reads as softened rather than as a
     separate faded picture. */
  96%  { stroke-dashoffset: 0; opacity: 1; animation-timing-function: ease-in; }
  /* Fade lands exactly on 100%, so there is no blank tail. The offset
     resets across the loop boundary instead of inside the timeline: at
     100% the line is transparent, and at 0% it is offset by a full length
     — hidden either way, so the jump between them cannot be seen and the
     next draw starts on the very next frame. */
  100% { stroke-dashoffset: 0; opacity: 0; }
}

/* Both keyed to the FIRST line arriving (30% of the cycle), not the last.
   Lines land between 30% and roughly 40% once the --i stagger is counted,
   so the light has to come up as the merge starts, not after it finishes.

   The dot is much faster than the haze on purpose: it snaps on as contact
   is made, and the glow swells around it a beat later.

   Two elements only. An intermediate "hot core" circle was tried between
   them and removed: its gradient ran out at its own radius while the aura
   was still going, so the edge showed as a distinct inner blob rather than
   reading as one light source. */
.hero.is-replaying .hero-glow-dot {
  animation: hero-glow-dot-cycle var(--hero-cycle) linear infinite;
}
.hero.is-replaying .hero-glow-aura {
  animation: hero-glow-aura-cycle var(--hero-cycle) linear infinite;
}

/* Both fade over the same eased 88-92% window as the lines, so the light
   goes out with the fan rather than lingering over an empty centre. */
/* The dot is full at exactly 30% — the frame the lines reach the centre —
   not after it. It ramps over the 0.1s immediately before, so the light is
   already there the instant contact is made rather than catching up. The
   old 28-35% ramp left it a quarter-second late, sized back when the
   per-line stagger meant lines arrived across a window instead of together. */
@keyframes hero-glow-dot-cycle {
  0%, 22% { opacity: 0; transform: scale(0.6); animation-timing-function: ease-out; }
  24%     { opacity: 1; transform: scale(1); }
  96%     { opacity: 1; transform: scale(1); animation-timing-function: ease-in; }
  100%    { opacity: 0; transform: scale(1); }
}

/* The haze still swells a beat behind the dot — the dot is the contact, the
   glow is what spreads from it — but from 30% rather than 28%, so it starts
   at the merge instead of before it. */
@keyframes hero-glow-aura-cycle {
  0%, 24% { opacity: 0; transform: scale(0.6); animation-timing-function: ease-out; }
  36%     { opacity: 1; transform: scale(1); }
  96%     { opacity: 1; transform: scale(1); animation-timing-function: ease-in; }
  100%    { opacity: 0; transform: scale(1); }
}

/* Reduced motion: the resting state above already is the finished picture,
   so this only has to stop the loop being applied. */
@media (prefers-reduced-motion: reduce) {
  .hero.is-replaying .hero-line,
  .hero.is-replaying .hero-glow-aura,
  .hero.is-replaying .hero-glow-dot { animation: none; }
}

/* Touch devices draw the fan ONCE and keep it, instead of looping.

   stroke-dashoffset is a paint property, so it can never reach the
   compositor: every frame of the loop re-rasterizes 68 stroked paths and
   their gradient paint servers. Desktop GPUs absorb that indefinitely;
   phone GPUs drop frames, and a dropped frame in a dash animation does not
   read as slowness but as the line jumping — the flicker reported on
   Android Chrome. contain:paint bounds the work but cannot make it cheap,
   and a compositing layer made it worse rather than better.

   So the animation stops being perpetual. The visitor still gets the
   draw-in, once, when the hero comes into view; after 1.2s the paths hold
   their finished state and nothing repaints again. That removes the cause
   outright rather than trying to afford it, and it is easier on battery
   too. The looping version stays on pointer devices, where it is free.

   Separate keyframes because the cycle ones end at opacity 0 — running
   those with fill:forwards would hold the fan invisible. These end on the
   drawn state, which is also .hero-line's resting state, so the handoff
   when the animation finishes is seamless.

   Timings keep the loop's PROPORTIONS but run at 0.625x its pace. At
   --hero-cycle 20s the loop draws over 0-24%, i.e. 4.8s; this draws in 3s,
   and every glow delay below is the matching loop percentage times the same
   0.625, so the dot still lands exactly on contact and the aura still
   swells from there.

   3s is the third value tried. 1.2s came first and read as the fan snapping
   into place; 4.8s matched desktop exactly and dragged, because a loop you
   watch repeat can afford a slower converge than a one-shot you see once on
   arrival. Duration was never the cause of the flicker — a perpetual
   repaint was — so the pace here is purely a feel decision. */
@media (hover: none) {
  .hero.is-replaying .hero-line {
    animation: hero-line-draw-once 3s cubic-bezier(0.65, 0, 0.35, 1) forwards;
  }
  /* Dot lands as the lines meet; the haze swells just behind it.

     fill-mode BOTH, not forwards. Both circles are opacity 1 at rest (that
     is the no-JS picture: drawn fan, lit centre), and these two animations
     are the only ones that start after a delay. forwards holds the end
     state but does nothing during the delay, so for the first full second
     the glow sat at its resting opacity — the light was already burning at
     full strength while the fan was still drawing toward it. both applies
     the 0% frame backwards through the delay as well, so the centre stays
     dark until the lines actually arrive.

     Loop percentages off --hero-cycle 20s, each scaled by the same 0.625.
     Dot ramps 22-24% (4.4s + 0.4s) -> 2.75s + 0.25s, full exactly as the 3s
     line draw lands. Aura runs 24-36% (4.8s + 2.4s) -> 3s + 1.5s, swelling
     from that same instant. Retiming the draw means rescaling all three by
     one factor, or the dot drifts off the moment of contact. */
  .hero.is-replaying .hero-glow-dot {
    animation: hero-glow-in-once 0.25s ease-out 2.75s both;
  }
  .hero.is-replaying .hero-glow-aura {
    animation: hero-glow-in-once 1.5s ease-out 3s both;
  }
}

@keyframes hero-line-draw-once {
  from { stroke-dashoffset: 1; }
  to   { stroke-dashoffset: 0; }
}

@keyframes hero-glow-in-once {
  from { opacity: 0; transform: scale(0.6); }
  to   { opacity: 1; transform: scale(1); }
}

/* A vignette centred on the convergence point, not a corner scrim. It has
   to sit above the lines to hold contrast for the copy, which means
   anything it paints over the meeting point comes straight off the glow —
   the first version was darkest at the bottom left and still had ~0.3
   opacity over the centre, which is what left the dot looking grey. Fully
   transparent at the centre, darkening only toward the edges where the
   body copy and quicklinks sit. */
.hero__wash {
  position: absolute;
  inset: 0;
  background: radial-gradient(78% 62% at 50% 40%, rgba(85,0,0,0) 0%, rgba(43,0,0,0.28) 58%, rgba(15,0,0,0.72) 100%);
  pointer-events: none;
}

.hero__inner {
  position: relative;
  z-index: 5;
  display: flex;
  flex-direction: column;
  /* The copy is centred between the two bars that overlay this section —
     the fixed header above, the quicklinks below. It used to be flex-end
     with a flat 186px bottom padding, which pinned it to the quicklinks
     and dumped every spare pixel into one gap up top, so it sat lower on
     short viewports and stranded a void on tall ones.

     Centring only reads as centred if the two paddings match, and both
     bars are --header-h tall, so both paddings are --header-h: equal
     space above the pill and below the button at every height. They also
     act as floors, keeping the copy clear of the header and the
     quicklinks rather than sliding under either.

     (The old value was clamp(186px, 108px + 18.2vw, 84px) — its minimum
     exceeded its maximum, and an inverted clamp() returns the minimum, so
     it resolved to a flat 186px at every width and the vw term never
     applied.) */
  /* flex-start with a vh top padding, not centring. The copy has to hang
     just below the point where the lines meet, and that point is a fixed
     fraction of the viewport: the graphic is preserveAspectRatio="slice",
     so on a typical desktop window the convergence at y=371 of the 931-unit
     viewBox lands around 40vh. Centring instead places the block by how
     much space happens to be left over, which put the headline above the
     glow rather than under it. 42vh starts the copy just past the meeting
     point — that value is the knob if the gap wants to be tighter or
     looser. */
  justify-content: flex-start;
  /* 1 0 auto, and no min-height:0 — this must never be squeezed below its
     own content, or the copy spills over the quicklinks bar beneath it. */
  flex: 1 0 auto;
  /* 42vh places the copy just under the meeting point, but as a pure
     fraction it stops working on a short window: the padding shrinks more
     slowly than the room the copy needs, and below ~810px of viewport
     height the two collided. Common on Windows, where 125-150% display
     scaling leaves a 1080p screen reporting only ~600px of CSS height.

     There is no ceiling on it any more. It used to be
     min(42vh, calc(100vh - 32rem)), where the second term reserved a fixed
     block for everything below. That reserve was a flat rem value while the
     copy was sized off width, so the two disagreed on short windows: the
     reserve won and dragged the headline UP — above the convergence point,
     which is the overlap where the dot sat inside the headline.

     It is gone because --hero-u removed the need for it. The composition now
     scales with height as well as width, so it shrinks to fit rather than
     having to be clamped into place. And 42vh is provably below the dot at
     every window: the graphic covers the box, so the convergence point lands
     at min(50vh - 6.215vw, 40.387vh), and both branches are under 42vh for
     any window wider than it is tall. On a window too short even for the
     scaled-down composition, .hero's min-height lets the section grow and
     the page scroll a little — which is the honest outcome, and cannot
     produce a collision the way the reserve could. */
  padding-top: 42vh;
  /* Gap between the supporting line and the quicklinks bar. In --hero-u so
     it scales with the rest of the composition; 0.36 x 100px = the 36px the
     design frame uses. */
  padding-bottom: calc(0.36 * var(--hero-u));
}

/* Mobile: the banner alone owns the first viewport, and the quicklinks drop
   into normal flow underneath it so they start below the fold instead of
   overlaying the bottom of it. That means the height moves from .hero to
   .hero__inner — .hero itself grows to banner + quicklinks — and the
   224px bottom padding that used to reserve room for the three overlaid
   rows is no longer needed. dvh so the banner ends at the true bottom edge
   while the browser chrome is showing; the vh line is the fallback. */
@media (max-width: 767px) {
  .hero { height: auto; }
  .hero__inner {
    height: 100vh;
    height: 100dvh;
    /* The mobile fan meets higher up its own viewBox than the desktop one
       does, so the copy starts higher to match — same relationship,
       different number. 38, not the earlier 30: at 30 the headline's first
       line began level with the meeting point and the dot landed on top of
       the type instead of sitting clear above it. */
    padding-top: 38dvh;
    /* No quicklinks bar overlaying the banner here (they sit in flow below
       it), so the bottom just needs a closing gutter, not a bar's height. */
    padding-bottom: var(--gap-lg);
  }
  /* Mobile keeps the auto margin. The objection to it on desktop is that it
     turns the gap into a slack bucket whose size depends on leftover room —
     but here .hero__inner is exactly 100dvh, a known box, and the design
     asks for the paragraph to sit just above the fold rather than a fixed
     distance under the button. Pinning it to the bottom is the requirement
     at this size, not an accident of layout. */
  .hero__body-row { margin-top: auto; }
}

/* Centred, and centred on the convergence point specifically — the whole
   composition reads as the lines meeting and the headline hanging directly
   beneath that meeting. Left-aligned copy puts the glow off to one side of
   the text and the relationship disappears. */
.hero__content {
  /* Figma's 820px up to 1440, widening with the type above that (see the
     large-screen block in tokens.css) — otherwise the 78px headline would
     wrap to an extra line on a 4K display rather than reading larger. */
  max-width: clamp(820px, 588.571px + 16.0714vw, 1000px);
  margin-inline: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  /* Was a flat --gap-md. Same 36px at 1440, but now scaling with the
     headline it sits under instead of holding still while the type grows. */
  gap: var(--hero-title-gap);
}

.hero__title { opacity: 0.96; }
.hero__cta { align-self: center; }

/* The hero composition in --hero-u. Every multiplier is that element's own
   size at the 1440x900 design frame divided by 100, so at that frame this
   block changes nothing at all — 0.62 x 100 = the 62px --fs-h1 already
   gives, 1.12 x 100 = the 112px gap, and so on. Away from it, all of them
   move together by one factor instead of each interpolating along width at
   its own rate, which is what left the type nearly full-size in a window
   20% shorter than the design.

   Desktop only. Below 768 the hero is a different composition — .hero__inner
   is exactly 100dvh, the graphic is the mobile SVG, and the supporting line
   is pinned above the fold rather than set a fixed distance under the
   button — so it keeps the ordinary width-based type scale. */
@media (min-width: 768px) {
  .hero {
    --hero-title-gap: calc(0.36 * var(--hero-u));
    --hero-body-gap: calc(1.12 * var(--hero-u));
  }
  .hero__title {
    font-size: calc(0.62 * var(--hero-u));
    line-height: calc(0.744 * var(--hero-u));
  }
  .hero__body {
    font-size: calc(0.2 * var(--hero-u));
    line-height: calc(0.3 * var(--hero-u));
  }
  .hero__cta {
    font-size: calc(0.18 * var(--hero-u));
    padding: calc(0.12 * var(--hero-u)) calc(0.24 * var(--hero-u));
  }
}

/* Hard break in the headline, desktop only — it balances the two lines at
   the width the copy was written for. Below that the line can't hold
   anyway and the break would only force an odd rag, so it collapses. */
.hero__title-break { display: none; }
@media (min-width: 1024px) {
  .hero__title-break { display: inline; }
}

/* An explicit gap, not margin-top:auto.

   auto pinned the supporting line to the bottom of .hero__inner, which made
   the space above it the section's slack bucket: every pixel not used by
   anything else landed in this one gap. That is why it measured 112px at
   1440 but 393px at 2560 — not a gap that scales, a void that grows. It
   also meant the gap shrank toward nothing on short windows while the
   copy above it was pushed up into the graphic.

   --hero-body-gap is 112px at 1440, so the design frame renders exactly as
   before; either side of it the gap now scales with the type rather than
   with whatever room happens to be spare. The slack it used to swallow
   collects below the paragraph instead, above the quicklinks bar, where
   empty space in a full-height banner belongs.

   The margin has to sit on the .container wrapper, not the <p> inside it:
   the container is what .hero__inner lays out as a flex item. */
.hero__body-row { margin-top: var(--hero-body-gap); }
.hero__body {
  max-width: clamp(708px, 512.571px + 13.5714vw, 860px);
  margin-inline: auto;
  text-align: center;
}

/* relative + z-index, because .hero__lines and .hero__wash are positioned
   and would otherwise paint over an unpositioned sibling. */
.hero__quicklinks {
  position: relative;
  z-index: 6;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-xxs);
  padding-inline: var(--container-pad);
  height: var(--header-h);
  /* No fill and no blur — the links sit straight on the hero. Both existed
     to hold contrast over the old background video; over the line graphic
     the tint just read as a dark band across the bottom and the blur
     smeared the curves running behind it. */
}

.hero__quicklinks a { display: inline-flex; align-items: center; gap: var(--gap-xxs); }
.hero__quicklinks img { width: 16px; }

@media (max-width: 767px) {
  /* The bar follows the full-height banner in flow, so the first viewport
     holds the headline, CTA and supporting copy, and only these links sit
     past the fold. */
  .hero__quicklinks {
    flex-direction: column;
    align-items: stretch;
    height: auto;
    gap: 0;
    padding-inline: 0;
  }
  /* Same as desktop: no fill, no blur. The border-top stays — stacked, the
     rows need a divider between them, which the desktop row doesn't. */
  .hero__quicklinks a {
    justify-content: space-between;
    padding: 18px var(--container-pad);
    border-top: 0.25px solid var(--color-bone);
  }
}

/* ---------------------------------------------------------------------- */
/* Capabilities intro + tiles + Offerings                                  */
/* Figma merges these into one continuous white block on mobile (single   */
/* frame) and keeps them as two contiguous frames on desktop — modeled    */
/* here as one section so the tiles→offerings gap matches exactly at      */
/* both breakpoints instead of being split across two independent         */
/* section paddings.                                                      */
/* ---------------------------------------------------------------------- */
.capabilities-offerings {
  background: var(--color-white);
  padding-top: var(--caps-top);
  padding-bottom: var(--caps-top);
}

.section-intro {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--gap-md);
  text-align: center;
  max-width: 795px;
  margin-inline: auto;
}

.section-intro--dark { color: var(--color-bone); }

.tiles {
  margin-top: var(--caps-intro-gap);
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--gap-xs);
}

/* Tablet: two per row. Between the phone's single column and the desktop
   masonry's three, one full-width card per row leaves a 656px tile holding
   a single line of copy and a lot of empty colour. Two columns put the tile
   at 320px here — close to the 360px the desktop composition is drawn
   around, so the copy and the graphic below it sit at proportions the
   design already accounts for.

   A plain grid, not the masonry: the stagger exists to offset a middle
   column, and there isn't one with an even number of columns. */
@media (min-width: 768px) and (max-width: 1023px) {
  .tiles {
    grid-template-columns: 1fr 1fr;
    gap: var(--tile-gap);
  }
}

.tile {
  position: relative;
  overflow: clip;
  padding: var(--tile-pad);
  height: var(--tile-h);
  display: flex;
  flex-direction: column;
  gap: var(--gap-xxs);
  color: var(--color-bone);
}

/* Scroll-reveal for the tile cards — ROW-based, not whole-grid: js/main.js
   runs two independent IntersectionObservers, one per row (tiles 1–3,
   tiles 4–6), each using a shrunk "centered band" root so a row only
   triggers once it's actually centered in the viewport, not just
   partially visible. Each observer adds .is-visible directly to its own
   row's three tiles — there's no longer a single shared container class
   gating this, which is what lets the two rows fire independently.
   transform only ever holds this translateY here — desktop's masonry
   placement below uses top/left, not transform, so they don't conflict.
   nth-child delays reset per row (4/5/6 reuse 1/2/3's pattern) since each
   row's .is-visible lands at its own, independent moment — the delay is
   relative to THAT event, not to page load or to the other row.

   0.7s with a 0.1s stagger, so the last card of a row is settled 0.9s
   after the row triggers. It was 3s with a 0.4s stagger — 3.8s for that
   last card, long enough that scrolling at any normal speed left cards
   visibly fading in well after they had arrived. */
.tile {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.7s ease, transform 0.7s ease;
}
.tile.is-visible { opacity: 1; transform: translateY(0); }
/* No per-card transition-delay. The stagger used to be nth-child based
   (0 / 0.1 / 0.2 repeating), which assumed cards 1-3 and 4-6 were the two
   visual rows — only true in the 3-across layout. Now that each card
   reveals on its own intersection (see js/main.js), the spacing comes from
   when each one actually scrolls in: one after another when stacked, in
   pairs at two-up, in threes at three-up. An nth-child delay on top of
   that would fire cards out of order — at two-up, card 4 (delay 0s) would
   beat card 3 (0.2s) despite sitting to its right. */

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

/* Desktop: true masonry via absolute placement (matches Figma's own
   technique) — the middle column (usage + pricing) sits --tile-stagger
   lower than the outer two columns. Plain CSS Grid can't do this: a
   shared row track would either force all three columns to shift together
   or let the offset tile overlap the row below it. DOM order stays 1–6
   (reading order for mobile/AT); position is purely visual. Must come
   after the base .tile rule above so position:absolute wins the cascade.

   1024px, not 768px. Three columns need more width than a tablet has once
   the gutters are taken out: at 768px each tile was only 208px wide with a
   160px content column, so the body wrapped to five lines and the copy ran
   to 77% of a card whose height is fixed — overflowing it outright, and
   leaving the decorative graphic a 23% sliver at the bottom. Stacked at
   the same width the tile is 656px, the body is two lines, and the copy
   ends at 46%. Tablets therefore keep the single-column layout, and the
   masonry starts where there is actually room for it. */
@media (min-width: 1024px) {
  .tiles {
    position: relative;
    display: block;
    height: calc(2 * var(--tile-h) + var(--tile-gap) + var(--tile-stagger));
  }

  .tile {
    position: absolute;
    width: calc((100% - 2 * var(--tile-gap)) / 3);
  }

  .tile--brand   { left: 0; top: 0; }
  .tile--concept { left: calc(2 * ((100% - 2 * var(--tile-gap)) / 3 + var(--tile-gap))); top: 0; }
  .tile--usage   { left: calc((100% - 2 * var(--tile-gap)) / 3 + var(--tile-gap)); top: var(--tile-stagger); }
  .tile--social  { left: 0; top: calc(var(--tile-h) + var(--tile-gap)); }
  .tile--cx      { left: calc(2 * ((100% - 2 * var(--tile-gap)) / 3 + var(--tile-gap))); top: calc(var(--tile-h) + var(--tile-gap)); }
  .tile--pricing { left: calc((100% - 2 * var(--tile-gap)) / 3 + var(--tile-gap)); top: calc(var(--tile-stagger) + var(--tile-h) + var(--tile-gap)); }
}

.tile__num {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--fs-num-badge);
  line-height: 24px;
}

.tile__title { font-family: var(--font-serif); font-weight: 500; font-size: var(--fs-h2); line-height: var(--lh-h2); }
.tile__body { font-family: var(--font-body); font-weight: 300; font-size: var(--fs-body); line-height: var(--lh-body); }

/* Step the tile's type and padding down wherever its column is narrow.
   That is BOTH ends of the range, not just phones: stacked single-column
   below 768px, and again from 768px to ~1024px where the masonry goes
   three-across inside a container that is still small — at 768px each
   tile is only 208px wide, the narrowest it ever gets, while --tile-h is
   already up to 330px of fixed height. At the shared token sizes the copy
   overflowed the card by ~48px there.

   Text now ends around 57–65% of the card, freeing the lower band for the
   graphics anchored there. Trimming the padding to --gap-sm widens the
   column as well, so the longest body wraps to three lines instead of
   four — part of the saving comes from that rather than from size alone. */
@media (max-width: 1023px) {
  .tile { padding: var(--gap-sm); }
  /* The title is NOT overridden here — it stays on --fs-h2, which is the
     spec's 30px at the 430 mobile frame and scales itself down below that.
     Only the number and body step down, and only as far as the card's fixed
     height needs to hold both the copy and the graphic beneath it. Fluid,
     not flat, since the column varies across this range. */
  .tile__num { font-size: clamp(1.0625rem, 0.9911rem + 0.3571vw, 1.25rem); line-height: 1.25; }
  .tile__body { font-size: clamp(0.9375rem, 0.8899rem + 0.2381vw, 1.0625rem); line-height: 1.5; }
}

.tile__graphic { position: absolute; opacity: 0.3; pointer-events: none; }

/* Brand & Advertising (Card 1): each ring is a div sized exactly one
   radius wide / one diameter tall, with a true semicircular border on its
   right side (top/bottom-right-radius: 100% 50% — 100% of the box's own
   width and 50% of its own height both equal the radius, which is what
   keeps the arc circular rather than elliptical) and a plain straight
   border on its flat left (diameter) side — matching the reference SVG,
   whose stroked path closes with a straight line back down that same
   edge rather than leaving it open. (Drawing the flat edge as the box's
   own border — square corners on the left, radiused on the right — is
   what keeps that line clean; the earlier version that got a full circle
   only 50% of the way there via clip/overflow instead flared into a
   visible blob right at the tangent point where the clip cut across the
   curve, which is what actually needed fixing, not the line's presence.)
   Percentages for left/top/width/height (set inline per ring) are that
   ring's pivot-x, center-y, and radius from the reference SVG, divided by
   the 404x229 viewBox — safe to do independently (not aspect-ratio-locked
   to each other) because the wrapper itself is pinned to
   aspect-ratio:404/229, so a % of its width and a % of its height always
   scale by the same factor. */
.ring-arc {
  position: absolute;
  box-sizing: border-box;
  border: 2px solid var(--color-salmon);
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
  border-top-right-radius: 100% 50%;
  border-bottom-right-radius: 100% 50%;
}

/* Ripple-in reveal: rings scale up from their own pivot edge (transform-
   origin: 0% — the flat side, so each ring grows outward from the corner
   like an actual ripple, not from its own center) in smallest-to-largest
   order, matching the markup's DOM order. Triggered by the same
   .tile.is-visible as its own card (tile--brand is nth-child(1) of row 1,
   delay 0 — see .tile:nth-child(1) above — so these start exactly when
   its card starts fading in, not before). */
.ring-arc {
  opacity: 0;
  transform: scale(0.4);
  transform-origin: 0% 50%;
  transition: opacity 3s ease, transform 3s ease;
}
.tile.is-visible .ring-arc { opacity: 1; transform: scale(1); }
.ring-arc:nth-child(1) { transition-delay: 0s; }
.ring-arc:nth-child(2) { transition-delay: 0.08s; }
.ring-arc:nth-child(3) { transition-delay: 0.16s; }
.ring-arc:nth-child(4) { transition-delay: 0.24s; }
.ring-arc:nth-child(5) { transition-delay: 0.32s; }
.ring-arc:nth-child(6) { transition-delay: 0.4s; }
.ring-arc:nth-child(7) { transition-delay: 0.48s; }
.ring-arc:nth-child(8) { transition-delay: 0.56s; }
.ring-arc:nth-child(9) { transition-delay: 0.64s; }

@media (prefers-reduced-motion: reduce) {
  .ring-arc { transition: none; }
}

/* Concept Testing (Card 3): each ring is a single div filled solid,
   clipped to a hollow diamond by an SVG <clipPath> (defs live inline in
   index.html, right before .tile__graphic--diamonds — CSS clip-path:
   polygon() can't do a true two-loop ring, only <clipPath>'s
   multi-subpath <path> can; see the HTML comment there for why).

   Each ring's inner subpath is its outer diamond's vertices offset a
   uniform 2pt *perpendicular to the edge* toward the center — for a
   rhombus with half-width HW, half-height HH and edge length
   L=√(HW²+HH²), offsetting every edge inward by distance d moves the
   x-axis vertex to HW−d·L/HH and the y-axis vertex to HH−d·L/HW
   (derived by shifting each edge's line equation HH·x+HW·y=HW·HH inward
   along its own normal and re-intersecting with the axes). A naive
   "nudge every vertex by the same d on its own axis" — i.e. HW−d,
   HH−d — under-insets the tips relative to the edges on a shape this
   elongated (HW ≫ HH here), which is what read as visibly
   thicker/darker strokes along the edges than at the tips. */
.diamond-ring { position: absolute; inset: 0; background: var(--color-maroon); }

/* Grow-in reveal, smallest to largest (DOM order) — same mechanics as
   .ring-arc above, scaling from center since a diamond (unlike Card 1's
   arcs) has no single pivot edge to grow from. tile--concept is
   nth-child(3) of row 1, whose own card-level delay is 0.8s (see
   .tile:nth-child(3) above); these delays start at that same 0.8s
   baseline so the rings only begin revealing once their card has
   actually started fading in, not while it's still fully transparent. */
.diamond-ring {
  opacity: 0;
  transform: scale(0.4);
  transition: opacity 3s ease, transform 3s ease;
}
.tile.is-visible .diamond-ring { opacity: 1; transform: scale(1); }
.diamond-ring:nth-child(1) { transition-delay: 0.8s; }
.diamond-ring:nth-child(2) { transition-delay: 0.88s; }
.diamond-ring:nth-child(3) { transition-delay: 0.96s; }
.diamond-ring:nth-child(4) { transition-delay: 1.04s; }
.diamond-ring:nth-child(5) { transition-delay: 1.12s; }
.diamond-ring:nth-child(6) { transition-delay: 1.2s; }

@media (prefers-reduced-motion: reduce) {
  .diamond-ring { transition: none; }
}

/* Pricing & Value (Card 5): 6 identical bordered ellipses (inset:0;
   margin:auto centers each — simpler than the earlier cards' left/top
   math since this wrapper is square, aspect-ratio:1, so width- and
   height-based percentages are already equal), each just rotated to a
   different angle inline (see index.html) — rotating the whole
   .tile__graphic--atom wrapper then spins every ellipse together as one
   rigid pattern, which is what "the graphic rotating along its center"
   means here (as opposed to each ellipse spinning independently).
   Static until tile--pricing (nth-child(5) of row 2, delay 0.4s — see
   .tile:nth-child(5) above) reveals, then spins once (fill:forwards
   holds it at the end state, which — since rotate(360°) looks identical
   to rotate(0°) — is indistinguishable from its original static pose).

   cubic-bezier(0.16,1,0.3,1) ("ease-out-expo") instead of linear: the
   spin now visibly decelerates into that final resting pose rather than
   stopping abruptly at a constant speed. */
.tile.is-visible .tile__graphic--atom {
  animation: atom-spin 3s cubic-bezier(0.16, 1, 0.3, 1) 1 forwards;
  animation-delay: 0.4s;
}
.atom-ring {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 19.56%;
  height: 99.42%;
  box-sizing: border-box;
  border: 2pt solid var(--color-light-teal);
  border-radius: 50%;
}

@keyframes atom-spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  .tile__graphic--atom { animation: none; }
}

/* Usage & Attitudes (Card 2): 6 static horizontal lines; 5 balls, each
   wrapped in a .usage-ball-clip sized to the small window it's allowed
   to show through (from the reference SVG's per-ball clip rect) — the
   .usage-ball inside starts pushed down by its own full height
   (translateY(100%), i.e. entirely below the clip window and therefore
   invisible) and springs up to rest, "emerging from hiding behind the
   line." Ball height (set inline per ball) is expressed as a % of its
   OWN clip parent, not the graphic wrapper, since each clip window is a
   different height but every ball is the same true size. */
.balls-line { position: absolute; left: 0; right: 0; height: 0; border-top: 2px solid var(--color-burnt-orange); }
.usage-ball-clip { position: absolute; overflow: hidden; }
.usage-ball {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  border-radius: 50%;
  background: var(--color-burnt-orange);
  box-sizing: border-box;
  transform: translateY(100%);
  transition: transform 3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.tile.is-visible .usage-ball { transform: translateY(0); }

@media (prefers-reduced-motion: reduce) {
  .usage-ball { transition: none; }
}

/* Social Diligence (Card 4): 12 real SVG <ellipse> elements sharing a
   center (see index.html for the cx/cy/rx/ry values — same geometry the
   old left%/width%/height% divs resolved to, just expressed as real SVG
   coordinates instead). Static here: the actual draw-in
   (stroke-dasharray/stroke-dashoffset via Element.animate(), staggered
   outer-ellipse-first) is entirely JS-driven, the same technique as
   Card 6's cube lines, since it needs each ellipse's real measured
   perimeter (getTotalLength()) — see the "Card 4: ellipse draw-in"
   block in js/main.js. */
.orb-ellipse { fill: none; stroke: var(--color-aegean-blue); stroke-width: 2; }

.tile--brand { background: var(--color-burnt-orange); }
.tile--usage { background: var(--color-peach); color: var(--color-burnt-orange); }
.tile--concept { background: var(--color-salmon); color: var(--color-maroon); }
.tile--social { background: var(--color-light-teal); color: var(--color-aegean-blue); }
.tile--pricing { background: var(--color-aegean-blue); }
.tile--cx { background: var(--color-teal); }

/* Card 4 at every width, not just the narrow ones. Its 12 ellipses share a
   centre, so the intended read — stated in the markup — is the top arc of
   each, down to roughly its widest point, rising above the card's bottom
   edge. Show the whole thing and the arcs close into complete ellipses,
   which reads as an eye rather than as a graphic running off the card.

   Sized off the card's HEIGHT, not its width, so the same fraction is on
   screen everywhere: the tile's proportions swing from 342x300 stacked to
   389x390 on desktop, and a width percentage yields a different crop at
   each. max-width caps it at 105%, the width the desktop composition
   already uses, so it never sprawls past a narrow card.

   top:63% is where it sits. The inline bottom:-62% it replaces put the
   arcs' top at ~46% of the card while this card's body — the longest of
   the six — runs to 64%, so the curves crossed the text. 63% clears the
   copy comfortably at the widths that matter — it ends at 58% on a 390px
   phone, 55% two-up, 54% at 1023 — and at 1440, where the copy runs its
   longest at 64%, the arcs' very tips overlap its last line by about 4px.
   That is the thin end of the outermost curve at 30% opacity, so it reads
   as the graphic passing behind the text rather than colliding with it.

   Two ranges it does not clear, both pre-existing: the 1024-1200 band,
   where three columns are cramped enough that the copy alone reaches
   79-89%, and a 320px handset, where this card's sentence wraps far
   enough to reach 77%.

   !important because the position is an inline style on the element. */
.tile__graphic--orbs {
  top: 63% !important;
  bottom: auto !important;
  height: 100% !important;
  width: auto !important;
  max-width: 105% !important;
  left: 50% !important;
  margin-left: 0 !important;
  transform: translateX(-50%) !important;
}

/* Mobile: the tiles stack single-column and stay short (--tile-h bottoms
   out at 300px), so the per-graphic positions (inline in index.html,
   lifted from each source SVG) — which are tuned for the desktop card
   proportions — ride up through the title and body at this size.

   Fix is to re-anchor each graphic by its TOP edge (top:…; bottom:auto)
   rather than its bottom: the top edge is the one that has to clear the
   text, and anchoring it directly keeps that clearance fixed no matter
   how tall the card or the graphic itself resolves to at a given viewport
   width (graphic heights come from aspect-ratio × a % width, so a
   bottom-anchored offset drifts as the viewport changes). Whatever hangs
   past the card is cropped by .tile's own overflow:clip — the same
   "cropped by the card edge" treatment the desktop composition already
   relies on for cards 3 and 4.

   The anchors sit just under where the copy ends — 57–65% of the card,
   once the type step-down above has applied — rather than near the bottom
   edge. An earlier pass put them at 68–78%, which cleared the full-size
   text but left so little of each graphic showing that they read as
   cropped fragments; these values roughly double the visible area of
   every one of them.

   !important is unavoidable here: the properties being overridden are
   inline styles, which outrank any stylesheet rule without it. */
@media (max-width: 1023px) {
  .tile__graphic--rings    { top: 62% !important; bottom: auto !important; }
  .tile__graphic--diamonds { top: 58% !important; bottom: auto !important; }
  .tile__graphic--atom     { top: 60% !important; left: 50% !important; }
  .hex-draw                { top: 62% !important; }
  /* Card 2 is the exception: six 2px rules are far less intrusive behind
     text than the other cards' large curves, and top-anchoring them would
     push two of the six lines (plus a ball) off the bottom edge entirely.
     With the shorter copy there is now room to restore the original
     bottom:0, so all six lines and all five balls are on the card. */
  .tile__graphic--balls    { bottom: 0 !important; }
}

/* ---------------------------------------------------------------------- */
/* Offerings list                                                          */
/* ---------------------------------------------------------------------- */
.offerings {
  margin-top: var(--caps-to-offerings-gap);
}

.offerings__intro {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--offerings-intro-gap);
  text-align: center;
  max-width: 795px;
  margin-inline: auto;
}

.offerings__list {
  margin-top: var(--offerings-to-list-gap);
  display: flex;
  flex-direction: column;
}

.offering-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--gap-md);
  padding-block: var(--card-pad-y);
  border-top: 0.5px solid var(--color-maroon);
  color: var(--color-maroon);
}

.offerings__list .offering-row:last-child { border-bottom: 0.5px solid var(--color-maroon); }

.offering-row__left { display: flex; align-items: center; gap: var(--gap-sm); }

.offering-row__index {
  font-family: var(--font-mono);
  font-size: var(--fs-offering-index);
  line-height: 25.6px;
}

.offering-row__title {
  font-family: var(--font-serif);
  font-weight: 500;
  font-size: var(--fs-h2);
  line-height: var(--lh-h2);
  opacity: 0.96;
}

.offering-row__body {
  font-family: var(--font-body);
  font-weight: 300;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  max-width: 483px;
}

@media (max-width: 767px) {
  .offering-row { flex-direction: column; gap: var(--gap-xxs); }
}

/* ---------------------------------------------------------------------- */
/* Stats band v2 — maroon w/ angular badges (Figma node 26:3212)           */
/* Desktop-sourced exactly; no Figma mobile frame exists yet for this      */
/* redesign, so the mobile layout (stacked badges, fluid sizing) is an     */
/* extrapolation of the same system rather than a 1:1 Figma match.         */
/* ---------------------------------------------------------------------- */
.stats {
  position: relative;
  background: var(--color-maroon);
  color: var(--color-white);
  padding-block: var(--gap-xxl);
  overflow: clip;
  box-sizing: border-box;
}

@media (min-width: 768px) {
  .stats { min-height: var(--stats-h); display: flex; align-items: center; padding-block: 0; }
}

.stats__lede {
  max-width: 825px;
  font-family: var(--font-serif);
  font-weight: 500;
  font-size: var(--fs-h2);
  line-height: var(--lh-h2);
}

/* The closing line starts its own line at every width. display:block rather
   than a <br> so the clause still wraps normally within itself on narrow
   viewports instead of being forced to break in a fixed place. */
.stats__lede-break { display: block; }

.stats__row {
  margin-top: var(--stats-lede-gap);
  display: flex;
  flex-direction: column;
  gap: var(--gap-xxl);
}

.stat { display: flex; flex-direction: column; gap: var(--gap-sm); flex: none; }

.stat__badge {
  position: relative;
  width: 100%;
  aspect-ratio: var(--stat-badge-ratio);
  display: flex;
  align-items: center;
  justify-content: center;
  clip-path: polygon(68.58% 0%, 0% 0%, 0% 70.08%, 30.54% 100%, 100% 100%, 100% 30.68%);
}
.stat__badge--salmon { background: var(--color-salmon); }
.stat__badge--peach { background: var(--color-peach); }
.stat__badge--white { background: var(--color-white); }

.stat__value {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--stat-badge-fs);
  line-height: 1.27;
  color: var(--color-maroon);
}

.stat__copy { display: flex; flex-direction: column; gap: var(--gap-xs); }
.stat__label { font-family: var(--font-mono); font-weight: 500; font-size: var(--fs-stat-label); line-height: var(--lh-stat-label); text-transform: uppercase; }
.stat__body { font-family: var(--font-body); font-weight: 300; font-size: var(--fs-body); line-height: var(--lh-body); color: var(--color-white); }

/* Columns are fluid and merely *capped* at the Figma width, rather than
   pinned to it. Three hard 360px badges plus their gaps need a ~1200px
   container, and --container-pad scales with the viewport (9.505vw), so
   that container only exists at viewports ≥1440px — at 1200px it is
   1006px, at 768px only 656px. Below 1440 the third column therefore ran
   past the container and .stats' own overflow:clip cut it off. flex:1 with
   max-width keeps the layout identical from 1440 up (items cap at 360 and
   space-between spreads the slack into the usual 60px gutters) while
   letting them shrink together on tablets and smaller laptops. */
@media (min-width: 768px) {
  /* flex-start, not flex-end. Bottom-aligning made each column's badge
     position depend on how many lines its label and body happened to wrap
     to, so the badges staggered into a staircase as soon as the columns
     stopped coming out the same height (which is any width narrower than
     full desktop). Top-aligning pins all three badges to one line, and
     since the badges are equal height the labels line up under them too. */
  .stats__row { flex-direction: row; align-items: flex-start; justify-content: space-between; gap: var(--gap-md); }
  .stat { flex: 1 1 0; min-width: 0; max-width: var(--stat-badge-w); }
  .stat__badge { width: 100%; }
}

/* ---------------------------------------------------------------------- */
/* Built by experts + quality-checks ticker (Figma node 65:6641)           */
/* ---------------------------------------------------------------------- */
.expertise {
  background: var(--color-white);
  padding-top: var(--expertise-top);
  padding-bottom: var(--expertise-top);
}

.expertise__grid {
  display: flex;
  flex-direction: column;
  gap: var(--expertise-gap);
  align-items: center;
}

.expertise__copy { display: flex; flex-direction: column; gap: var(--gap-md); max-width: 402px; }
.expertise__copy .h2 { color: var(--color-maroon); }
.expertise__copy .body-text { color: var(--color-black); }

/* Ticker box: 8 rows of horizontally scrolling checklist terms, each row
   looping seamlessly via a translateX(-50%) animation over a doubled
   track (see setupQualityTicker in js/main.js). */
.expertise__showcase {
  position: relative;
  width: 100%;
  background: var(--color-salmon);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--gap-xs);
  padding-block: var(--gap-lg);
  box-sizing: border-box;
  height: clamp(380px, 270px + 22vw, 476px);
}

/* Mobile (6-col grid): heading centered while the body copy stays
   left-aligned. Must come after the base rules above so these overrides
   win the cascade.

   The showcase's padding-block is a flat 64px top+bottom at every
   breakpoint (var(--gap-lg) doesn't scale down), which leaves only
   ~252px of the 380px mobile floor for the 8 rows of ticker text —
   less than the ~317px they actually need (8 × 25.6px line-height + 7
   × 16px row gap), so rows were getting sliced off top/bottom by the
   showcase's own overflow:hidden. Cut the vertical padding down on
   mobile so all 8 rows fit without clipping. */
@media (max-width: 767px) {
  .expertise__copy { text-align: left; }
  .expertise__copy .h2 { text-align: center; }
  .expertise__showcase { padding-block: var(--gap-xs); }
}

/* Desktop: explicit 12-column sub-grid (max-width 1200px content, 16px
   gutter) — text spans columns 1–4 (left), a one-column gap, the ticker
   spans 6–12 (7 cols). Must come after all the base rules above so these
   overrides win the cascade. */
@media (min-width: 768px) {
  .expertise__copy { grid-column: 1 / 5; max-width: none; }
  .expertise__grid {
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    column-gap: 16px;
    align-items: start;
  }
  .expertise__showcase { grid-column: 6 / 13; height: 476px; }
}

.qc-row { overflow: hidden; }
.qc-track {
  display: inline-flex;
  align-items: center;
  gap: var(--gap-tiny);
  list-style: none;
  margin: 0;
  padding: 0;
  width: max-content;
  animation: qc-scroll-left 100s linear infinite;
}
.qc-row--right .qc-track { animation-name: qc-scroll-right; }
[data-paused="true"] .qc-track { animation-play-state: paused; }

@keyframes qc-scroll-left {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}
@keyframes qc-scroll-right {
  from { transform: translateX(-50%); }
  to { transform: translateX(0); }
}

.qc-check {
  display: inline-flex;
  align-items: center;
  gap: var(--gap-tiny);
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: var(--fs-caption);
  line-height: 25.6px;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--color-white);
  transition: color 0.4s ease;
}
.qc-check__icon { width: 17px; height: 11px; flex: none; stroke: currentColor; }
.qc-check.is-active { color: var(--color-maroon); }

/* --fs-caption floors at 13px, but pin an explicit, larger floor
   on mobile — plus the heavier weight/letter-spacing/text-shadow above —
   so the ticker text is unambiguously legible against the salmon panel
   at small sizes. */
@media (max-width: 767px) {
  .qc-check { font-size: 1rem; }
}

@media (prefers-reduced-motion: reduce) {
  .qc-track { animation: none; }
}

.pre-reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}
.pre-reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
  .pre-reveal { opacity: 1; transform: none; transition: none; }
}

/* ---------------------------------------------------------------------- */
/* ---------------------------------------------------------------------- */
/* Industry veterans                                                       */
/* ---------------------------------------------------------------------- */
.veterans {
  background: var(--color-maroon);
  color: var(--color-bone);
  padding-block: var(--gap-xl);
  box-sizing: border-box;
}

@media (min-width: 768px) {
  .veterans {
    padding-block: var(--gap-xxl);
  }
}

/* The grid container holds both member rows with clean vertical rhythm */
.veterans__grid {
  display: flex;
  flex-direction: column;
  gap: var(--gap-xl);
}

@media (min-width: 768px) {
  .veterans__grid {
    gap: clamp(48px, 6vw, 80px);
  }
}

/* Individual member rows */
.veterans__row {
  display: flex;
  flex-direction: column-reverse;
  gap: var(--gap-md);
  width: 100%;
}

.veterans__row--reverse {
  flex-direction: column;
}

/* Photo and caption stack together as one column */
.veterans__aside {
  display: flex;
  flex-direction: column;
  gap: var(--gap-xs);
  width: 100%;
  max-width: 320px;
  min-width: 0;
}

.veterans__photo-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-sm);
}

.veterans__photo-meta .caption {
  line-height: 1.35;
  text-transform: uppercase;
}

/* LinkedIn icon badge */
.veterans__linkedin {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 26px;
  height: 26px;
  background: var(--color-bone);
  transition: opacity 0.25s ease;
}
.veterans__linkedin:hover { opacity: 0.8; }
.veterans__linkedin img { width: 18px; height: 18px; object-fit: contain; }

/* Portrait photo frame with fixed 1:1 aspect ratio */
.veterans__photo {
  width: 100%;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  position: relative;
}
.veterans__photo img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center top;
}
.veterans__photo::after {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(85,0,0,0.1);
  pointer-events: none;
}

/* Bio copy column */
.veterans__copy {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-start;
  gap: var(--gap-md);
  width: 100%;
  min-width: 0;
}
.veterans__copy-top {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--gap-sm);
  width: 100%;
}
.veterans__actions {
  display: flex;
  align-items: center;
  gap: var(--gap-md);
  margin-top: var(--gap-xs);
}

/* Desktop: alternating 2-column layouts.
   Row 1: photo left (1fr), copy right (3fr).
   Row 2: copy left (3fr), photo right (1fr). */
@media (min-width: 768px) {
  .veterans__row {
    display: grid;
    grid-template-columns: 1fr 3fr;
    column-gap: var(--gap-lg);
    align-items: start;
    width: 100%;
  }
  .veterans__row--reverse {
    grid-template-columns: 3fr 1fr;
  }
  .veterans__aside {
    max-width: none;
    width: 100%;
  }
  .veterans__copy {
    width: 100%;
  }
}

/* Partner logos */
.veterans__logos {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: var(--gap-md);
  height: 24px;
}
.veterans__logos img {
  height: 100%;
  width: auto;
  max-width: 100%;
  object-fit: contain;
}

.veterans__logos--mousumi {
  height: 32px;
  gap: var(--gap-lg);
}
.veterans__logo-kantar {
  height: 18px !important;
  width: auto;
}
.veterans__logo-fs {
  height: 30px !important;
  width: auto;
}

@media (max-width: 767px) {
  .veterans__logos {
    gap: var(--gap-sm);
    height: 18px;
  }
  .veterans__logos--mousumi {
    height: 26px;
    gap: var(--gap-md);
  }
  .veterans__logo-kantar {
    height: 14px !important;
  }
  .veterans__logo-fs {
    height: 22px !important;
  }
}

/* ---------------------------------------------------------------------- */
/* Articles                                                                 */
/* ---------------------------------------------------------------------- */
.articles {
  background: var(--color-white);
  padding-top: var(--articles-top);
  padding-bottom: var(--card-pad-y);
}

.articles__head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--gap-md);
  color: var(--color-maroon);
}

.articles__head .caption { max-width: 483px; }

/* Desktop only. The cards carry border-top on mobile (they stack there;
   above 768px that swaps to a left border between them — see .article-card),
   so on a phone this rule and the first card's own border draw two parallel
   lines a few px apart under the heading. On desktop the cards have no top
   border and this is the only line, which is the intent. */
.articles__divider { display: none; }
@media (min-width: 768px) {
  .articles__divider { display: block; margin-top: var(--gap-sm); height: 1px; background: var(--color-maroon); opacity: 0.4; }
}

.articles__list {
  margin-top: var(--gap-sm);
  display: flex;
  flex-direction: column;
}

@media (min-width: 768px) {
  .articles__list { flex-direction: row; align-items: stretch; }
}

.article-card {
  flex: 1 1 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--gap-sm);
  padding-block: var(--card-pad-y);
  border-top: 1px solid rgba(85,0,0,0.15);
}

.article-card__content { display: flex; flex-direction: column; gap: 24px; }

@media (min-width: 768px) {
  .article-card { border-top: none; padding-inline: var(--gap-md); }
  .article-card:first-child { padding-inline-start: 0; }
  .article-card:last-child { padding-inline-end: 0; }
  .article-card + .article-card { border-left: 1px solid rgba(85,0,0,0.15); }
}

.article-card__eyebrow { color: var(--color-salmon); }
.article-card__title { color: var(--color-maroon); opacity: 0.96; }
.article-card__body { color: var(--color-maroon); }

/* ---------------------------------------------------------------------- */
/* Closing CTA + footer group                                              */
/* Sized together (not the CTA alone) to one viewport height, so the       */
/* footer sits flush with the bottom of the screen rather than the pair    */
/* adding up to more than 100vh. The CTA is the flexible one — it grows to */
/* make up the difference; the footer keeps its natural content height.    */
/*                                                                         */
/* min-height, not height: on a phone the CTA's own content (a display-    */
/* size heading wrapping to three lines, plus body and button) can need    */
/* more room than a viewport leaves once the footer has taken its share.   */
/* A hard height forced the overflow back onto the content, and since the  */
/* CTA centres its children that spilled off both ends at once — which is  */
/* what pushed the heading against the section's top edge. Growing past    */
/* 100vh in that case is the correct behaviour; it costs nothing, as this  */
/* is the bottom of the page.                                              */
/* ---------------------------------------------------------------------- */
.closing-group {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.closing-cta {
  /* 1 0 auto: still grows to absorb slack on tall screens, but never
     shrinks below its own content + padding, which is the other half of
     what let the heading get squeezed against the top edge. */
  flex: 1 0 auto;
  background: var(--color-peach);
  color: var(--color-maroon);
  box-sizing: border-box;
  padding-inline: var(--container-pad);
  /* Real block padding, so the space above the heading and below the
     button is a guaranteed minimum rather than whatever justify-content
     happens to have left over — on mobile that leftover was nothing. */
  padding-block: var(--gap-xl);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: var(--gap-md);
}

.closing-cta__title { opacity: 0.96; max-width: 850px; }
.closing-cta__body { max-width: 592px; }

/* This CTA gets the brand's maroon-fill treatment instead of .btn-cta's
   default bone fill — scoped here rather than on .btn-cta itself, since
   the contact-form submit button also uses .btn-cta and stays as-is.
   filter:brightness for hover/active (not .btn-cta's inset overlay,
   which assumes a light background) matches the same maroon-button
   pattern already used for .contact-form__submit. */
.closing-cta .btn-cta {
  background: var(--color-maroon);
  color: var(--color-bone);
  border-color: var(--color-maroon);
}
.closing-cta .btn-cta:hover { filter: brightness(1.18); box-shadow: none; }
.closing-cta .btn-cta:active { filter: brightness(1.32); box-shadow: none; }

/* ---------------------------------------------------------------------- */
/* Footer                                                                   */
/* Not part of the Figma file (the design ends at the closing CTA) — built */
/* to match the site's existing design system (colors, type, spacing).     */
/* ---------------------------------------------------------------------- */
.site-footer {
  flex: none;
  background: var(--color-peach);
  color: var(--color-maroon);
}

/* Brand mark reuses the same bone-filled SVG as the header; the footer's
   peach background needs it in maroon instead. Italic accents (e.g. an
   italicized word inside footer copy) are untouched and stay salmon via
   the shared .italic-accent rule. */
.site-footer .brand__logo circle { fill: var(--color-maroon); }
.site-footer .brand__name { color: var(--color-maroon); }

.site-footer__top {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--gap-md);
  padding-block: var(--gap-lg) var(--gap-md);
  border-bottom: 0.5px solid rgba(85, 0, 0, 0.3);
}

@media (min-width: 768px) {
  .site-footer__top { flex-direction: row; align-items: center; justify-content: space-between; }
}

.site-footer__nav ul {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gap-sm);
  list-style: none;
  margin: 0;
  padding: 0;
}

@media (min-width: 768px) {
  .site-footer__nav ul { gap: var(--nav-links-gap); }
}

.site-footer__nav a {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: var(--fs-caption);
  color: var(--color-maroon);
  text-transform: uppercase;
}

.site-footer__social {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
}

.site-footer__social a {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  border: 1px solid rgba(85, 0, 0, 0.4);
  border-radius: 50%;
  color: var(--color-maroon);
}

.site-footer__social svg { width: 18px; height: 18px; }

.site-footer__bottom {
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
  padding-block: var(--gap-md);
}

@media (min-width: 768px) {
  .site-footer__bottom { flex-direction: row; align-items: center; justify-content: space-between; }
}

.site-footer__copyright {
  font-family: var(--font-mono);
  font-size: var(--fs-caption);
  color: rgba(85, 0, 0, 0.7);
}

.site-footer__legal {
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
}

.site-footer__legal a {
  font-family: var(--font-mono);
  font-size: var(--fs-caption);
  color: rgba(85, 0, 0, 0.7);
  text-decoration: underline;
  text-underline-offset: 3px;
}
.site-footer__legal a:hover { color: var(--color-maroon); }

/* ---------------------------------------------------------------------- */
/* Contact modal                                                           */
/* Opened by any .js-open-contact button (see js/main.js) — hidden via the */
/* [hidden] attribute rather than a CSS class alone, so a no-JS visitor    */
/* never sees an unstyled dialog sitting inline in the page; JS clears     */
/* [hidden] on open and restores it after the close transition ends.      */
/* body.has-modal-open locks page scroll while it's open.                  */
/* ---------------------------------------------------------------------- */
/* .has-nav-open is the mobile nav panel's equivalent lock (it covers the
   full viewport below the bar) — separate class, same effect, so the two
   can't unlock each other when the nav's CTA opens this modal. */
body.has-modal-open,
body.has-nav-open { overflow: hidden; }

.contact-modal {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--gap-md);
  overflow-y: auto;
}
.contact-modal[hidden] { display: none; }

/* A soft frosted-glass scrim (same backdrop-filter:blur pattern as the
   site header/hero quicklinks) rather than a dark tint — a heavy dark
   overlay read as too stark/harsh behind the light modal dialog. */
.contact-modal__backdrop {
  position: fixed;
  inset: 0;
  background: rgba(248, 247, 245, 0.55);
  backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity 0.3s ease;
}
.contact-modal.is-open .contact-modal__backdrop { opacity: 1; }

.contact-modal__dialog {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 640px;
  max-height: 90vh;
  overflow-y: auto;
  margin: auto;
  box-sizing: border-box;
  background: var(--color-bone);
  color: var(--color-black);
  border-radius: 16px;
  padding: var(--gap-lg) var(--gap-md);
  opacity: 0;
  transform: translateY(24px) scale(0.98);
  transition: opacity 0.3s ease, transform 0.3s ease;
}
.contact-modal.is-open .contact-modal__dialog {
  opacity: 1;
  transform: translateY(0) scale(1);
}

.contact-modal__close {
  position: absolute;
  top: var(--gap-sm);
  right: var(--gap-sm);
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  border-radius: 50%;
  color: var(--color-maroon);
  transition: background-color 0.15s ease;
}
.contact-modal__close:hover,
.contact-modal__close:focus-visible { background: rgba(85, 0, 0, 0.08); }
.contact-modal__close svg { width: 18px; height: 18px; }

/* The shared .brand__logo/.brand__name are bone-colored by default (built
   for the maroon header) — the modal has a bone background, so recolor
   them maroon here, the same way .site-header--solid already does. */
.contact-modal__brand { margin-bottom: var(--gap-md); }
.contact-modal__brand .brand__logo circle { fill: var(--color-maroon); }
.contact-modal__brand .brand__name { color: var(--color-maroon); }

.contact-modal__heading { color: var(--color-maroon); margin-bottom: var(--gap-xs); }
.contact-modal__subtext { color: var(--color-black); margin-bottom: var(--gap-md); }

.contact-form { display: flex; flex-direction: column; }
.contact-form__fields { display: flex; flex-direction: column; gap: var(--gap-sm); }
/* Explicit, because the display:flex above outranks the browser's own
   [hidden] { display: none } — without this the fields stay on screen when
   js/main.js hides them after a successful submit, leaving a filled-in form
   sitting next to the "Thanks" message. Same guard as .contact-modal[hidden]. */
.contact-form__fields[hidden] { display: none; }

.contact-form__row { display: flex; gap: var(--gap-sm); }
.contact-form__row .contact-form__field { flex: 1 1 0; min-width: 0; }

.contact-form__field { display: flex; flex-direction: column; gap: var(--gap-tiny); }

.contact-form__label {
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: var(--fs-caption);
  letter-spacing: 0;
  text-transform: uppercase;
  color: var(--color-maroon);
}
.contact-form__required {
  color: var(--color-salmon);
  margin-left: 2px;
}

.contact-form__input {
  width: 100%;
  box-sizing: border-box;
  font-family: var(--font-body);
  font-size: var(--fs-body);
  color: var(--color-black);
  background: var(--color-white);
  border: 1px solid rgba(85, 0, 0, 0.25);
  border-radius: 6px;
  padding: 12px 14px;
}
.contact-form__input:focus-visible {
  outline: 2px solid var(--color-bright-blue);
  outline-offset: 1px;
}
.contact-form__select { appearance: auto; }
.contact-form__textarea { resize: vertical; min-height: 96px; }

.contact-form__submit { margin-top: var(--gap-xs); width: 100%; }
/* The submit CTA reads as the primary action, so it gets the site's dark
   (maroon-fill) button treatment instead of .btn-cta's default bone fill —
   overridden here rather than on .btn-cta itself, since every other
   "Get in touch" trigger on the page should keep the light/outline style. */
.contact-form__submit {
  background: var(--color-maroon);
  color: var(--color-bone);
  border-color: var(--color-maroon);
}
.contact-form__submit:hover { filter: brightness(1.18); box-shadow: none; }
.contact-form__submit:active { filter: brightness(1.32); box-shadow: none; }

.contact-form__success {
  font-family: var(--font-body);
  font-size: var(--fs-body);
  color: var(--color-teal);
  text-align: center;
}

/* Shown in place of the success note when the Web3Forms POST fails, so a
   dropped submission can't masquerade as a delivered one. */
.contact-form__error {
  font-family: var(--font-body);
  font-size: var(--fs-body);
  color: var(--color-burnt-orange);
  text-align: center;
  margin-top: var(--gap-md);
}

/* Honeypot — must stay in the DOM and focus order for bots to take the bait,
   so it is hidden outright rather than merely moved offscreen. */
.contact-form__botcheck {
  display: none !important;
}

.contact-form__submit[disabled] {
  opacity: 0.6;
  cursor: not-allowed;
}

@media (max-width: 767px) {
  .contact-modal { padding: 0; }
  .contact-modal__dialog {
    max-width: none;
    max-height: none;
    min-height: 100%;
    border-radius: 0;
  }
  .contact-form__row { flex-direction: column; }
}

/* Desktop (>=768px): a wide, two-column dialog instead of a tall single
   card — .contact-modal__header (logo, heading, subtext) and .contact-form
   are already direct siblings inside .contact-modal__dialog (see
   index.html), so making the dialog itself the grid is all that's
   needed; nothing in the markup has to change to go from stacked to
   side-by-side. align-items defaults to stretch here, so both columns
   share the taller one's height, and justify-content:center on each
   (now display:flex) vertically centers its own content within that
   shared height. Padding moves from the dialog onto each column
   individually so the left column's maroon background can run flush to
   the dialog's own edges/corners instead of leaving a bone-colored
   margin around it. */
@media (min-width: 768px) {
  .contact-modal__dialog {
    display: grid;
    grid-template-columns: 1fr 1fr;
    max-width: 1040px;
    /* No fixed height here — the row sizes to the form column's natural
       content height (the taller side), and the shorter header column
       gets stretched to match. The gap/padding/input-size reductions
       below keep that natural height inside the base rule's max-height:
       90vh, so every field and the submit CTA are visible without
       scrolling on ordinary desktop viewports. */
    padding: 0;
  }

  .contact-modal__header {
    background: var(--color-maroon);
    padding: var(--gap-md) var(--gap-md);
    border-radius: 16px 0 0 16px;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  /* Shared .brand__logo/.brand__name default to bone (built for the
     maroon header) — the mobile single-column layout above recolors them
     maroon for its bone background, but this left column IS maroon, so
     the shared bone default is exactly right again; only the two
     mobile-only overrides need cancelling. */
  .contact-modal__brand .brand__logo circle { fill: var(--color-bone); }
  .contact-modal__brand .brand__name { color: var(--color-bone); }
  .contact-modal__heading { color: var(--color-bone); }
  .contact-modal__subtext { color: var(--color-bone); opacity: 0.85; }

  .contact-form {
    /* Extra top padding clears the dialog's close button, which is
       absolutely positioned in the dialog's own top-right corner and
       would otherwise sit on top of the first field row. */
    padding: 64px var(--gap-md) var(--gap-xs) var(--gap-md);
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  /* Tighter vertical rhythm than the mobile form uses — with 8 fields
     to show at once, the mobile spacing/input sizing would push the
     submit CTA below the fold. */
  .contact-form__fields { gap: 12px; }
  .contact-form__input { padding: 9px 14px; }
  .contact-form__textarea { min-height: 44px; }
}

@media (prefers-reduced-motion: reduce) {
  .contact-modal__backdrop,
  .contact-modal__dialog { transition: none; }
}

/* ---------------------------------------------------------------------- */
/* Mobile section rhythm                                                   */
/* ---------------------------------------------------------------------- */
/* Every section gets the same top and bottom inset below 768px. Each one   */
/* otherwise carries its own Figma-derived token — --caps-top,             */
/* --expertise-top, --articles-top, --gap-xxl, --gap-xl — and because      */
/* those clamps scale on different curves they diverged at phone widths:   */
/* veterans ended up at 36px against stats at 96px, and articles was       */
/* asymmetric (64px top, 24px bottom). Desktop keeps the per-section        */
/* values, which are deliberate there.                                     */
/*                                                                         */
/* Last in the file on purpose: these have the same specificity as the     */
/* rules they replace, so source order is what settles it.                 */
/* ---------------------------------------------------------------------- */
@media (max-width: 767px) {
  .capabilities-offerings,
  .stats,
  .expertise,
  .articles,
  .closing-cta {
    padding-block: var(--section-pad-mobile);
  }

  /* Veterans puts its inset on the row rather than the section, so the
     maroon block always keeps a margin around its contents — see the note
     above .veterans__grid. */
  .veterans__grid {
    padding-top: var(--section-pad-mobile);
    padding-bottom: var(--section-pad-mobile);
  }
}
