/* ============================================================
   IShowSpeed Foundation — main.css
   Mobile-first (390px reference). Tokens are layered:
     1. Primitives  — raw values
     2. Semantics   — purpose-named, alias primitives
     3. Component   — defined inline next to component rules
   Change a primitive to ripple a theme; reach for semantics in
   component code so intent reads cleanly.
   ============================================================ */


/* ---------- 0. PERF TIER ----------
   Devices flagged as low-tier by the inline perf detector (slow CPU,
   low memory, save-data, reduced-motion preference) get animations and
   transitions short-circuited so the heavy keyframes and parallax /
   webgl effects don't tank the frame rate. JS modules (parallax,
   worldmap, webgl, etc.) also short-circuit on `html.perf-low`. */
html.perf-low *,
html.perf-low *::before,
html.perf-low *::after {
  animation-duration: 0.001ms !important;
  animation-delay: 0s !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.001ms !important;
  transition-delay: 0s !important;
  scroll-behavior: auto !important;
}

/* ---------- 1. TOKENS ---------- */

:root {
  /* --- Primitive: color ---
     Light theme palette. Cream surface, ink text, royal blue accent
     replaces the lime green of the previous dark theme. `--color-green-live`
     stays green only for the "Live" status indicator inside the hero
     pill (kept per Figma). `--color-black`/`--color-white` retained so
     legacy rules that reference them by name still resolve. */
  --color-black:        #000;
  --color-white:        #fff;
  --color-cream-50:     #f6f6f0;
  --color-cream-100:    #f0f0e8;
  --color-cream-200:    #e7e7d9;
  --color-cream-300:    #d6d6c3;
  --color-cream-400:    #a5a593;
  --color-ink-600:      #747467;
  --color-ink-800:      #272621;
  --color-ink-900:      #171717;
  --color-blue-600:     #0052e9;
  --color-blue-700:     #0042c2;
  --color-green-live:   #48AC00;
  --color-orange-500:   #d97706;
  --color-orange-600:   #b45309;
  --color-orange-700:   #92400e;

  /* --- Primitive: spacing (4px scale) --- */
  --space-1:  4px;
  --space-2:  8px;
  --space-3:  12px;
  --space-4:  16px;
  --space-5:  20px;
  --space-6:  24px;
  --space-7:  28px;
  --space-8:  32px;
  --space-9:  36px;

  /* --- Primitive: radius --- */
  --radius-sm:    8px;
  --radius-md:    10px;
  --radius-lg:    12px;
  --radius-pill:  999px;

  /* --- Primitive: type families ---
     Site-wide swap to Plus Jakarta Sans (both display + body — it has
     a strong display range at Medium/SemiBold/Bold/ExtraBold) and
     Atkinson Hyperlegible Mono for small uppercase tags. */
  --font-display: "Plus Jakarta Sans", system-ui, -apple-system, sans-serif;
  --font-body:    "Plus Jakarta Sans", system-ui, -apple-system, sans-serif;
  --font-mono:    "Atkinson Hyperlegible Mono", ui-monospace, "SF Mono", monospace;

  /* --- Primitive: font size — body scale (prose, UI, labels) ---
     Fluid via clamp(min, intercept + slope·vw, max). The intercept is
     tuned so the value LOCKS at min until viewport ≥560px and reaches
     max at 1200px. Mobile (<560) keeps the hand-tuned designed sizes;
     larger viewports scale up linearly. */
  --fs-body-sm:  16px;  /* button, tag, tracker label, goal subtitle */
  --fs-body-md:  clamp(18px, 16.25px + 0.313vw, 20px);   /* default body copy */
  --fs-body-lg:  clamp(20px, 16.5px + 0.625vw, 24px);    /* attribution */
  --fs-body-xl:  clamp(26px, 13.75px + 2.188vw, 40px);   /* pull quote */

  /* --- Primitive: font size — display scale (headlines, wordmarks) --- */
  --fs-display-xs:  clamp(25px, 18.875px + 1.094vw, 32px);  /* header wordmark */
  --fs-display-sm:  clamp(48px, 20px + 5vw, 80px);          /* tracker amount */
  --fs-display-md:  clamp(50px, 13.25px + 6.563vw, 92px);   /* hero headline */
  --fs-display-lg:  clamp(56px, 14px + 7.5vw, 104px);       /* "The Africa Fund" */

  /* --- Primitive: font weight --- */
  --fw-medium:     500;
  --fw-semibold:   600;
  --fw-bold:       700;
  --fw-extrabold:  800;

  /* --- Primitive: line height (unitless) --- */
  --lh-display:  1;   /* hero headline */
  --lh-flat:     1;      /* aligned wordmarks / tags */
  --lh-button:   1.25;   /* button, attribution, tracker label */
  --lh-quote:    1.4;    /* pull quote, tracker goal */
  --lh-body:     1.45;    /* default prose */

  /* --- Primitive: letter spacing --- */
  --tracking-default:   -0.01em;  /* body copy */
  --tracking-tight:     -0.03em;  /* large body, attribution */
  --tracking-tighter:   -0.04em;  /* display amounts, tags */
  --tracking-tightest:  -0.05em;  /* display headlines */
  --tracking-logo:      -0.06em;  /* header wordmark */

  /* --- Semantic: surface & text --- */
  --bg:              var(--color-cream-50);
  --fg:              var(--color-ink-900);
  --accent:          var(--color-blue-600);
  --accent-soft:     var(--color-blue-700);

  --text-strong:     var(--color-ink-900);
  --text-muted:      var(--color-ink-800);
  --text-subtle:     var(--color-ink-600);
  --text-faint:      var(--color-cream-400);
  --text-logo:       var(--color-ink-900);
  --text-on-card:    var(--color-ink-800);
  --text-on-track:   var(--color-ink-600);
  --text-card-lede:  var(--color-ink-600);

  --surface-card:    var(--color-white);
  --surface-inner:   var(--color-cream-100);
  --surface-track:   var(--color-cream-100);

  --border-subtle:   var(--color-cream-200);
  --border-fainter:  var(--color-cream-200);
  --border-track:    var(--color-cream-300);

  --divider:         var(--color-cream-200);

  /* --- Layout ---
     Re-declared at min-width breakpoints (see §16 RESPONSIVE) so all
     layout-driven values scale through these tokens. Component code
     reads these; primitives stay constant. */
  --page-gutter:    20px;             /* static mobile padding (no centring bands) */
  --content-max:    100%;             /* fluid on mobile; capped from ≥560 up: 520 → 760 → 1200 */
  --section-pad-y:  var(--space-6);   /* 36px → 64 → 96 at desktop */
  --col-gap:        var(--space-6);   /* multi-col gutters at desktop */
  --hero-image-h:   193px;            /* 193 → 320 → 460 */

  /* --- Motion: easing --- */
  --ease-out-soft:     cubic-bezier(0.16, 1, 0.3, 1);   /* entrance settle */
  --ease-in-out-snap:  cubic-bezier(0.65, 0, 0.35, 1);  /* write-on cubic */

  /* --- Motion: "play" SVG write-on ---
     Per-letter draw uses one duration and a uniform stagger.
       • --play-draw-duration → how long one letter takes to wipe
       • --play-stagger       → gap between consecutive letter starts
       • --play-start-delay   → when the first letter (P) kicks off
     Total run = start-delay + 3·stagger + draw-duration. */
  --play-draw-duration: 280ms;
  --play-stagger:       200ms;
  --play-start-delay:   640ms;

  /* --- Motion: live pulse (campaign tag) ---
     Ring stagger is derived from the total cycle (½ cycle), so all
     animations stay locked in sync no matter what you tweak.
       • --pulse-duration  → one ring's pulse animation time
       • --pulse-rest      → silent gap added between consecutive pulses
     Total cycle = duration + rest. Set rest to 0 for a continuous
     heartbeat; raise it for a more deliberate pulse-pause-pulse beat. */
  --pulse-duration:            3.7s;
  --pulse-rest:                1.2s;
  --pulse-cycle:               calc(var(--pulse-duration) + var(--pulse-rest));
  --pulse-ring-ease:           var(--ease-out-soft);
  --pulse-ring-delay:          calc(var(--pulse-cycle) / 2);
  --pulse-ring-start-opacity:  0.35;
  --pulse-ring-max-scale:      4.2;
  --pulse-icon-scale-peak:     1.42;
  --pulse-icon-scale-dip:      0.96;
  --pulse-icon-opacity-dip:    0.65;
  --pulse-icon-glow-blur:      20px;
  --pulse-icon-glow-color:     rgba(0, 82, 233, 0.50);
}


/* ---------- 2. RESET ---------- */

*, *::before, *::after { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
}

/* Clip horizontal overflow at the root so the parallax-driven quote
   frame (which expands to 125% width on entry) can't shift the page
   wider than the viewport — otherwise mobile pinch-zoom lets you zoom
   out below the design baseline. `clip` (unlike `hidden`) doesn't
   create a new scroll/positioning context, so position: sticky on the
   site-header keeps working. */
html {
  overflow-x: clip;
}

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

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

p { margin: 0; }

h1, h2, h3, h4 { margin: 0; font-weight: var(--fw-extrabold); }


/* ---------- 3. BASE ---------- */

body {
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-body);
  font-size: var(--fs-body-md);
  font-weight: var(--fw-medium);
  line-height: var(--lh-body);
  letter-spacing: var(--tracking-default);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* Horizontal overflow is clipped at <html> via `overflow-x: clip`
     (see reset above). Leaving body's overflow as default `visible`
     here so it doesn't become a scroll container — `overflow-x: hidden`
     on body silently breaks position: sticky on the navbar, because
     the sticky descendant then anchors to body (which doesn't scroll
     internally — the viewport scrolls). */
}


/* ---------- 4. LAYOUT ---------- */

.page {
  position: relative;
  max-width: var(--content-max);
  margin: 0 auto;
  /* Contain the parallax quote-frame bleed (which expands to 125%
     width on entry) so it can't widen the page's measured width past
     the viewport. `clip` (unlike `hidden`) does not create a scroll
     container, so the sticky navbar still anchors to the viewport. */
  overflow-x: clip;
}

.divider {
  width: 100%;
  height: 1px;
  background: var(--divider);
  border: 0;
  margin: 0;
}

/* Vertical side rails (Figma Vector 29 + 30): start at the second
   horizontal divider and run to the bottom of the page. `isolation:
   isolate` creates a stacking context so the hairlines can sit at
   z-index: -1 inside .rails (below all content) without escaping to
   the page background. Section blocks that bleed past the gutter
   (e.g. .campaign__hero) then cover the hairlines cleanly. */
.rails {
  position: relative;
  isolation: isolate;
}
.rails::before,
.rails::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--divider);
  pointer-events: none;
  z-index: -1;
}
.rails::before { left: var(--page-gutter); }
.rails::after  { right: var(--page-gutter); }


/* ---------- 5. HEADER ----------
   Sticky on scroll. Two states: rest (full-size logo, donate CTA
   hidden) and `.is-compact` (logo scales down, donate CTA slides in
   from the right). The trigger flips when the hero donate button
   scrolls above the viewport — see /assets/js/header.js. */

.site-header {
  position: sticky;
  top: 0;
  z-index: 20;

  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);

  /* 24px all around so the logo sits with equal breath from the
     viewport top + sides + the hero card below. Desktop (§16c)
     tightens the verticals for the wider hero grid. */
  padding: var(--space-6);

  /* Mobile rest: TRANSPARENT — the logo sits directly inside the hero
     card's top area, no glass bar. The cream glass + hairline only
     fade in once the user scrolls past the hero (`.is-scrolled`), so
     the top-of-page reads as the Figma intends: clean photo card with
     the logo as the only chrome.

     Desktop (≥1080) re-enables a permanent glass background since the
     hero there is a 2-col grid (no photo to sit on top of). */
  background: transparent;
  border-bottom: 1px solid transparent;
  transition:
    padding 280ms var(--ease-out-soft),
    background 280ms var(--ease-out-soft),
    backdrop-filter 280ms var(--ease-out-soft),
    border-color 280ms var(--ease-out-soft);
}
/* Scrolled state: tighter vertical padding compresses the bar, and
   the cream surface fills in so the now-scrolling page content reads
   as fully behind the chrome rather than peeking through. Solid color,
   no backdrop blur — keeps the bar feeling like flat page chrome. */
.site-header.is-scrolled {
  padding-top: var(--space-3);
  padding-bottom: var(--space-3);
  background: var(--color-cream-50);
  border-bottom-color: var(--divider);
}
.site-header__logo {
  position: relative;
  width: 189px;
  height: 40px;
  font-family: var(--font-body);
  font-weight: var(--fw-extrabold);
  font-size: var(--fs-display-xs);
  line-height: 24px;
  letter-spacing: var(--tracking-logo);
  color: var(--text-logo);

  transform-origin: left center;
  /* Rest: nudge down so visible space above the mark matches the gap
     below it (header bottom pad + hero top pad). On scroll, drop the
     translate so the logo snaps up to true vertical center inside the
     now-bordered header, in the same 220ms transform interpolation. */
  transform: translateY(6px);
  transition: transform 220ms cubic-bezier(0.32, 0.72, 0, 1);
  will-change: transform;
  backface-visibility: hidden;
}
.site-header.is-scrolled .site-header__logo {
  transform: translateY(0) scale(0.82);
}
.site-header__line {
  display: block;
}
.site-header__line--indent {
  padding-left: 56px;
}
.site-header__mark {
  position: absolute;
  top: 1.5px;
  left: 0;
  height: 37px;
}

/* Compact donate CTA — hidden at rest, slides in when `.is-compact`
   is toggled on the header. `visibility` is delayed on hide so the
   element stays focusable through the fade-out, then drops out of
   the tab order once invisible. */
.site-header__cta {
  display: flex;
  opacity: 0;
  transform: translateX(8px);
  pointer-events: none;
  visibility: hidden;
  transition:
    opacity 240ms var(--ease-out-soft),
    transform 320ms var(--ease-out-soft),
    visibility 0s linear 320ms;
}
.site-header__cta .btn {
  padding: 9px 16px;
  font-size: 13px;
  letter-spacing: -0.02em;
  gap: 0;
}
.site-header__cta .btn__arrow {
  display: none;
}

.site-header.is-compact .site-header__cta {
  opacity: 1;
  transform: translateX(0);
  pointer-events: auto;
  visibility: visible;
  transition:
    opacity 280ms var(--ease-out-soft),
    transform 320ms var(--ease-out-soft),
    visibility 0s linear 0s;
}

@media (prefers-reduced-motion: reduce) {
  .site-header__logo,
  .site-header__cta {
    transition: opacity 120ms linear, visibility 0s linear 120ms;
    transform: none !important;
  }
  .site-header.is-compact .site-header__cta {
    transition: opacity 120ms linear, visibility 0s linear 0s;
  }
}


/* ---------- 6. HERO ----------
   Mobile / tablet: a single rounded photo card with the title, lede,
   tracker pill, and donate button overlaid at the bottom on a dark
   scrim. The header logo sits above the card in its own row (no glass
   bar — see §5 — so the top of the page reads as a clean Figma-style
   "logo, then photo card" sequence).
   Desktop (≥1080, see §16c): switches to a two-column grid — copy on
   the left, the same photo on the right; the overlay treatment turns
   off, the dark scrim is hidden, and the card padding collapses. */

.hero {
  /* Mobile photo framing knobs — only used by .hero__image below.
       --hero-image-pos-x : horizontal focal point, 0% (left) → 100% (right)
       --hero-image-pos-y : vertical focal point,   0% (top)  → 100% (bottom)
       --hero-image-scale : extra zoom on top of object-fit:cover,
                            1 = no zoom, e.g. 1.15 = 15% zoom-in
     Tweak any one inline (style="--hero-image-pos-y: 35%;") or via a
     stylesheet override to reframe the mobile crop without re-exporting
     the photo. Desktop (≥1080) sets its own object-position + clears
     the transform, so these have no effect there. */
  --hero-image-pos-x: 50%;
  --hero-image-pos-y: 100%;
  --hero-image-scale: 1.2;

  position: relative;
  /* 4px from each screen edge per the Figma. The card itself fills the
     remaining width via the inherited block default. */
  margin: 0 var(--space-1);
  padding: var(--space-6);
  border-radius: var(--radius-sm);
  overflow: hidden;
  /* Slightly shorter than the Figma 374 × 586 photo card so the hero
     reads as more compact on mobile and the campaign block above the
     fold gets earlier attention. */
  aspect-ratio: 374 / 550;

  /* Content (title, lede, pill, donate) stacks at the BOTTOM of the
     card via justify-end so the bright top of the photo stays clean
     and the dark scrim handles legibility below the midline. */
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: var(--space-4);

  /* Anchor the scroll-driven scale at the top so the card visually
     collapses downward + inward as the user scrolls past it. */
  transform-origin: center top;
}
.hero__image {
  /* Absolute fill — the photo IS the card background. inherit the
     parent radius so the corners stay clipped even with overflow. */
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  object-fit: cover;
  /* Framing driven by the --hero-image-* knobs on .hero (see above).
     transform-origin matches the object-position so the optional
     zoom centers on the chosen focal point. */
  object-position: var(--hero-image-pos-x) var(--hero-image-pos-y);
  transform: scale(var(--hero-image-scale));
  transform-origin: var(--hero-image-pos-x) var(--hero-image-pos-y);
  z-index: 0;
}
/* Dark gradient scrim — fades from clear at the top quarter to a
   near-opaque black past the midline so the overlaid title/lede/pill
   stay readable against any photo content. */
.hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, transparent 20%, rgba(0, 0, 0, 0.71) 50%);
  pointer-events: none;
  z-index: 1;
}
.hero__inner {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.hero__copy {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.hero__title {
  position: relative;
  font-family: var(--font-display);
  font-weight: var(--fw-medium);
  font-size: 44px;
  line-height: 1.1;
  letter-spacing: -0.04em;
  /* Pinned to white — the title sits over the dark photo + scrim
     regardless of theme. Desktop ≥1080 reverts to --text-strong since
     the title moves out of the photo overlay there. */
  color: var(--color-white);
  margin: 0;
  max-width: 100%;
}
.hero__title-line {
  display: block;
}
.hero__title-play {
  position: absolute;
  /* em-based so the Play badge tracks the title font-size at every
     breakpoint. Tuned against the 44px mobile baseline:
     51.1 ≈ 1.16em top, 199 ≈ 4.52em left, 102×58 ≈ 2.32em × 1.32em. */
  top: 1.16em;
  left: 4.52em;
  width: 2.32em;
  height: 1.32em;
  pointer-events: none;
  /* Play artwork color — the inline SVG inherits currentColor for its
     letter fills. Sky-blue reads against the dark hero photo. */
  color: #9fc1ff;
}
.hero__lede {
  font-family: var(--font-body);
  font-weight: var(--fw-medium);
  font-size: 18px;
  line-height: 1.5;
  letter-spacing: -0.02em;
  /* Pinned to white — see .hero__title. */
  color: var(--color-white);
  margin: 0;
  max-width: 100%;
}
.hero__lede-muted {
  color: rgba(255, 255, 255, 0.6);
}

/* Hero CTA group — wraps the dual buttons (Make a Donation + Learn more)
   into a horizontal row. On mobile the primary takes the full row so
   the secondary wraps to a new line at its natural width. */
.hero__cta-group {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

/* Mobile donate CTA — full-width primary inside the hero card with
   label-left, arrow-right. Desktop (§16c) resets to auto-width inline. */
.hero .btn--primary {
  width: 100%;
  justify-content: space-between;
  padding: 20px 24px;
  font-weight: var(--fw-bold);
  letter-spacing: -0.01em;
}
.hero .btn--secondary {
  padding: 20px 24px;
  font-weight: var(--fw-bold);
  align-self: flex-start;
}
.hero .btn__arrow {
  width: 16px;
  height: 12px;
}


/* ---------- 6b. HERO TRACKER PILL ----------
   Compact glassmorphic pill living inside the hero — shows the live
   Africa Fund total alongside the campaign label. Filled at build time
   from src/_data/fundraiser.js and updated live by fundraiser.js
   (which writes to [data-pill-raised] / [data-pill-goal] on each
   poll). Stretches to fill its column on mobile; caps at 360px on
   desktop. */

.hero-pill {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  width: 100%;
  /* Full-width on mobile; capped at 360px in the desktop column (§16c). */
  padding: 12px 20px;
  border-radius: 16px;
  /* Glass over the dark hero photo per the Figma — translucent white
     with a backdrop blur. Desktop ≥1080 swaps this for the cream
     surface tone since the pill there sits over the page bg, not the
     photo. */
  background: rgba(255, 255, 255, 0.09);
  backdrop-filter: blur(25.5px);
  -webkit-backdrop-filter: blur(25.5px);
}
.hero-pill__label {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-medium);
  font-size: 16px;
  line-height: 1.3;
  letter-spacing: -0.01em;
  /* Pinned to white over the dark hero photo. Desktop overrides this
     to --text-strong since the pill moves off the photo there. */
  color: var(--color-white);
  white-space: nowrap;
  min-width: 0;
}
.hero-pill__live {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  flex-shrink: 0;
}
.hero-pill__bracket {
  color: rgba(255, 255, 255, 0.36);
}
.hero-pill__live-text {
  /* Mobile/tablet: pill sits over the dark hero photo, so the live
     indicator uses a brighter lime (reads against the photo glass).
     Desktop ≥1080 reverts to --color-green-live on the cream pill. */
  color: #a3e635;
  font-weight: var(--fw-semibold);
}
.hero-pill__fund-name {
  overflow: hidden;
  text-overflow: ellipsis;
}
.hero-pill__stats {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  flex-shrink: 0;
}
.hero-pill__amount {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-medium);
  font-size: 16px;
  line-height: 1.3;
  letter-spacing: -0.03em;
  /* Pinned for the dark-photo overlay context. */
  color: var(--color-white);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
/* Numeric value + sign live together inside this span so the spacing
   between them doesn't shift while count-up runs. */
.hero-pill__amount-value {
  display: inline-flex;
  align-items: baseline;
}
/* Recessed dollar sign — sits beside the number but reads as a
   secondary character so the number is the primary tick. */
.hero-pill__amount-sign {
  opacity: 0.45;
  margin-right: 1px;
}
.hero-pill__dot {
  width: 4px;
  height: 4px;
  border-radius: 50%;
  /* Matches .hero-pill__live-text — brighter lime on the dark photo
     overlay. Desktop ≥1080 reverts to --color-green-live. */
  background: #a3e635;
  flex-shrink: 0;
  /* Two animations chained:
       1. pill-crystallize — soft rise + blur clear, in step with the
          rest of the pill children (see §13 HERO ANIMATIONS)
       2. hero-pill-dot-pulse — gentle live breathing once settled.
     The pulse delay = crystallize delay + duration (660 + 540 = 1200)
     so the dot starts pulsing exactly when it finishes settling.
     Without chaining, the second `animation:` would overwrite the
     entrance set by the generic children selector below. */
  animation:
    pill-crystallize 540ms var(--ease-out-soft) 660ms both,
    hero-pill-dot-pulse 1.8s ease-in-out 1200ms infinite;
}
@keyframes hero-pill-dot-pulse {
  0%, 100% { opacity: 1;    transform: scale(1);    }
  50%      { opacity: 0.55; transform: scale(1.25); }
}
@media (prefers-reduced-motion: reduce) {
  .hero-pill__dot {
    animation: none;
  }
}
.hero-pill__goal {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--fw-medium);
  font-size: 12px;
  line-height: 1.3;
  letter-spacing: -0.03em;
  color: rgba(255, 255, 255, 0.36);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}


/* ---------- 7. QUOTE ---------- */

.quote {
  margin: 0;
  padding: var(--space-8) var(--page-gutter) var(--space-9);
  display: flex;
  flex-direction: column;
  gap: var(--space-5); /* default rhythm; specific gaps below */
}
.quote__mark {
  width: 32px;
  height: 30px;
}
.quote__mark--close {
  align-self: flex-end;
  transform: rotate(180deg);
  margin-top: var(--space-5);
}

.quote__text {
  font-family: var(--font-body);
  font-weight: var(--fw-medium);
  font-size: var(--fs-body-xl);
  line-height: var(--lh-quote);
  letter-spacing: var(--tracking-tight);
  color: var(--text-strong);    /* fallback if JS doesn't run */
  /* Mobile cap — content reflows naturally up to the designer's preferred
     measure. The ≥560 rule swaps this for a ch-based cap. */
  max-width: 278px;
  margin-top: var(--space-5); /* 20px between quote-mark and text */

  /* Driven by quote-reveal.js: 0..1 as the paragraph travels through
     the reveal window in the viewport. --n is the word count. */
  --reveal: 0;
  --reveal-window: 0.18;
}
/* Per-word faded-ink → solid-ink interpolation. Each word claims a slice
   of the [0..1] reveal range starting at --i / --n; once --reveal passes
   that threshold the word's alpha lifts over --reveal-window's length.
   Starting alpha is 0.30 (not 0.25) because dark-on-light needs a touch
   more presence at the low end to feel deliberate. */
.quote__word {
  --threshold: calc(var(--i) / var(--n) * (1 - var(--reveal-window)));
  --word-t: clamp(0, calc((var(--reveal) - var(--threshold)) / var(--reveal-window)), 1);
  color: rgba(23, 23, 23, calc(0.30 + 0.70 * var(--word-t)));
}

.quote__image {
  position: relative;
  width: var(--quote-frame-w, 100%);
  height: var(--quote-frame-h, 144px);
  /* Auto margins can't go negative, so when the frame is wider than
     100% it would snap to the left edge. Computed inline-margins keep
     it centered through the full width range (sub-100% AND over). */
  margin-left:  calc((100% - var(--quote-frame-w, 100%)) / 2);
  margin-right: calc((100% - var(--quote-frame-w, 100%)) / 2);
  border-radius: var(--radius-sm);
  overflow: hidden;
  margin-top: var(--space-5); /* 20px gap above + below the image */
}
.quote__image-inner {
  position: absolute;
  left: 0;
  width: 100%;
  top: -18%;
  height: 136%;
  object-fit: cover;
  will-change: transform;
}

.quote__attribution {
  font-family: var(--font-body);
  font-weight: var(--fw-medium);
  font-size: var(--fs-body-lg);
  line-height: var(--lh-button);
  letter-spacing: var(--tracking-tight);
  color: var(--text-faint);
  text-align: right;
  margin-top: var(--space-8); /* 32px above attribution */

  /* Same scroll-driven gray→white reveal as .quote__text — driven by
     quote-reveal.js, interpolated per-word via the global .quote__word
     rule above. --n is set by JS from the split word count. */
  --reveal: 0;
  --reveal-window: 0.18;
}


/* ---------- 8. AFRICA FUND ---------- */

.campaign {
  /* Top padding is 0 because .campaign__hero (the green-bg block on
     mobile) carries its own vertical breathing room. The bottom
     padding sits between the lede and whatever follows the section.
     Desktop (≥1080) re-introduces a section padding-top — see §16c. */
  padding: 0 var(--page-gutter) var(--space-9);
  /* Clears the sticky header when the /africa deep link scrolls here. */
  scroll-margin-top: 88px;
}

/* Blue full-bleed "campaign hero" block. Wraps the tag, title, lede,
   and photo cluster on accent blue with white type. Extends to the
   viewport edges via negative inline margins (escapes the .campaign
   horizontal padding). At ≥1080 (see §16c) the panel becomes an
   internal 2-col editorial grid (copy left, images right) while
   keeping the blue full-bleed treatment. */
.campaign__hero {
  position: relative;
  margin-inline: calc(-1 * var(--page-gutter));
  padding: var(--space-9) var(--page-gutter);
  background: var(--color-blue-600);
  color: var(--color-white);
  overflow: hidden;
  margin-bottom: 0;
}

/* Tag inside the blue hero — strip the pulsing-dot decoration, swap
   to the Atkinson Hyperlegible Mono uppercase treatment in white.
   The text uses a terminal-style type-on animation when the section
   scrolls into view (an inline script adds .is-typed; see index.njk):
   characters reveal one at a time via a steps() width animation, a
   "big cursor" block follows the typing tip, blinks a few times once
   typing finishes, then morphs into a small white square that gently
   pulses to indicate "live." All elegant white-on-blue, no extra
   chroma. */
.campaign__hero .campaign__tag {
  margin-left: 0;
  margin-bottom: var(--space-6);
  align-items: center;        /* cursor block aligns vertically with text */
}
.campaign__hero .campaign__tag-pulse {
  display: none;
}
.campaign__hero .campaign__tag-text {
  font-family: var(--font-mono);
  font-weight: var(--fw-medium);
  font-size: 16px;
  letter-spacing: 0;
  line-height: 1;
  color: var(--color-white);
  text-transform: uppercase;

  /* Terminal typewriter: a fixed-width inline-block reveals chars
     left-to-right via a steps() width animation. "LIVE CAMPAIGN" is
     13 characters (incl. the space). Initial width 0 keeps the text
     hidden until .is-typed turns the animation on. */
  display: inline-block;
  overflow: hidden;
  white-space: nowrap;
  vertical-align: bottom;
  width: 0;
}
/* Cursor block — a flex sibling of .campaign__tag-text. Because the
   text container's width is the one animating, the cursor naturally
   tracks the typing tip. Hidden by default; animations are scoped to
   .is-typed so they trigger on the scroll-in IntersectionObserver. */
.campaign__hero .campaign__tag::after {
  content: "";
  display: inline-block;
  flex-shrink: 0;
  width: 0.6em;
  height: 1em;
  margin-left: 2px;
  background: var(--color-white);
  opacity: 0;
}

/* Once .is-typed is set (by the inline IIFE near the campaign section),
   the type-on, blink, collapse, and live pulse all sequence off.
   Timeline: 0.2s in, cursor appears. 0.3-1.0s, text types out at
   ~54ms/char. 1.1-2.3s, 3 cursor blinks. 2.3-2.75s, cursor morphs
   into a small square. 2.85s+, gentle live pulse. */
.campaign__hero .campaign__tag.is-typed .campaign__tag-text {
  animation: tag-type 0.7s steps(13, end) 0.3s forwards;
}
.campaign__hero .campaign__tag.is-typed::after {
  animation:
    tag-cursor-appear   0.1s 0.2s both,
    tag-cursor-blink    0.4s 1.1s 3 both,
    tag-cursor-collapse 0.45s 2.3s var(--ease-out-soft) both,
    tag-live-pulse      2.4s 2.85s ease-in-out infinite;
}

@keyframes tag-type {
  from { width: 0; }
  to   { width: 13ch; }
}
@keyframes tag-cursor-appear {
  from { opacity: 0; }
  to   { opacity: 1; }
}
/* Soft square-wave blink — ends back at opacity 1 so the collapse
   morphs from a visible cursor (no flicker between phases). */
@keyframes tag-cursor-blink {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0; }
}
/* Morph the rectangular cursor into a small rounded square — the
   block "tucks away" rather than blinking out, and lands as the live
   indicator. */
@keyframes tag-cursor-collapse {
  to {
    width: 0.42em;
    height: 0.42em;
    margin-left: 0.55em;
    border-radius: 2px;
  }
}
/* Gentle live pulse — opacity + slight scale breathing. Black on
   lime, no glow or color shift — keeps the elegance the design asks
   for. */
@keyframes tag-live-pulse {
  0%, 100% { opacity: 1;    transform: scale(1);    }
  50%      { opacity: 0.35; transform: scale(0.7);  }
}

/* Reduced motion: skip the whole performance — text shown at full
   width immediately, cursor block hidden. */
@media (prefers-reduced-motion: reduce) {
  .campaign__hero .campaign__tag-text {
    width: auto;
    animation: none;
  }
  .campaign__hero .campaign__tag::after {
    display: none;
  }
}

/* Title inside the blue hero — Plus Jakarta Sans Bold (not Extrabold)
   in white, per the Figma. */
.campaign__hero .campaign__title {
  font-family: var(--font-display);
  font-weight: var(--fw-bold);
  font-size: 56px;
  line-height: 0.96;
  letter-spacing: -0.05em;
  color: var(--color-white);
  width: auto;
  max-width: 14ch;
  margin-bottom: var(--space-9);
}

.campaign__tag {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2); /* 6 → use 8 from scale; visual diff is 2px */
  margin-left: -6px; /* dot has glow halo, lift visually flush to gutter */
  margin-bottom: var(--space-5);
}
.campaign__tag-pulse {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
}
.campaign__tag-pulse::before,
.campaign__tag-pulse::after {
  content: "";
  position: absolute;
  inset: 25%; /* originate from the visual center of the 16px icon */
  border-radius: 50%;
  background: var(--accent);
  opacity: 0;
  transform: scale(1);
  animation: tag-pulse-ring var(--pulse-cycle) var(--pulse-ring-ease) infinite;
  pointer-events: none;
}
.campaign__tag-pulse::after {
  animation-delay: var(--pulse-ring-delay);
}
.campaign__tag-dot {
  width: 16px;
  height: 16px;
  position: relative; /* keep icon above the pulse rings */
  animation: tag-pulse-icon var(--pulse-cycle) ease-in-out infinite;
}

/* Ring active phase ends at 40% of the cycle, then holds invisible
   to 50% (where the staggered partner ring emits). That ~10% slice
   on either side of the cycle is the silent gap controlled by
   --pulse-rest — grow rest to grow the gap. */
@keyframes tag-pulse-ring {
  0% {
    opacity: var(--pulse-ring-start-opacity);
    transform: scale(1);
  }
  40%, 100% {
    opacity: 0;
    transform: scale(var(--pulse-ring-max-scale));
  }
}

@keyframes tag-pulse-icon {
  0%, 50%, 100% {
    transform: scale(var(--pulse-icon-scale-peak));
    opacity: 1;
    filter: drop-shadow(0 0 var(--pulse-icon-glow-blur) var(--pulse-icon-glow-color));
  }
  25%, 75% {
    transform: scale(var(--pulse-icon-scale-dip));
    opacity: var(--pulse-icon-opacity-dip);
    filter: drop-shadow(0 0 0 transparent);
  }
}

@media (prefers-reduced-motion: reduce) {
  .campaign__tag-pulse::before,
  .campaign__tag-pulse::after,
  .campaign__tag-dot {
    animation: none;
  }
}
.campaign__tag-text {
  font-family: var(--font-body);
  font-weight: var(--fw-bold);
  font-size: var(--fs-body-sm);
  letter-spacing: var(--tracking-tighter);
  color: var(--accent);
  line-height: var(--lh-flat);
}

.campaign__title {
  font-family: var(--font-body);
  font-weight: var(--fw-extrabold);
  font-size: var(--fs-display-lg);
  line-height: var(--lh-display);
  letter-spacing: var(--tracking-tightest);
  color: var(--text-strong);
  /* Mobile cap — reflows up to this measure. ≥560 swaps for `12ch`. */
  max-width: 253px;
  margin-bottom: var(--space-6);
}

/* Photo cluster — overlapping diagonal layout matching the Figma:
   SPA in upper-right, ANGOLA in lower-left with a ~120px Y offset
   between their tops. Container is positioned absolutely and lives
   inside .campaign__hero, which is overflow: hidden so any edge
   spillover at narrow viewports gets trimmed cleanly.
   At ≥840 the existing same-specificity rule below converts this to
   a 12-col side-by-side grid; at ≥1080 photos go back to flow-based
   with grid-column allocations. */
.campaign__images {
  position: relative;
  height: 417px;
  max-width: none;
  margin: 0;
}
.campaign__image {
  position: absolute;
  border-radius: 0;
  object-fit: cover;
  will-change: transform; /* parallax.js drives translate3d on scroll */
}
.campaign__image--left {
  left: 0;
  top: 120px;
  width: 198px;
  height: 297px;
}
.campaign__image--right {
  left: 131px;
  top: 0;
  width: 211px;
  height: 264px;
}

.campaign__lede {
  font-size: var(--fs-body-md);
  line-height: var(--lh-body);
  letter-spacing: var(--tracking-default);
  /* White inside the blue campaign__hero panel. */
  color: var(--color-white);
  /* Mobile cap — reflows up to this measure. ≥560 swaps for `52ch`. */
  max-width: 339px;
  /* No bottom margin on mobile — the .tracker-section sibling carries
     its own padding-top, so spacing to the tracker comes from there. */
}
.campaign__lede-muted {
  color: rgba(255, 255, 255, 0.65);
}
/* Inline link inside the campaign lede (e.g. Common Goal partner link).
   The global reset strips underlines, so we restore one with a small
   offset for breathing room and fade the text on hover. */
.campaign__lede a {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: opacity 200ms var(--ease-out-soft);
}
.campaign__lede a:hover,
.campaign__lede a:focus-visible {
  opacity: 0.7;
}


/* ---------- 9. TRACKER ----------
   The live <africa-fund-tracker> Web Component lives in its own
   section below the Africa Fund story block (see .tracker-section).
   Component styles live in /assets/css/africa-fund-tracker.css. */
.tracker-section {
  /* Tighter top gap to the campaign block above; full breathing room
     stays on the bottom so the tracker has space before the next
     section. Horizontal padding is 4px LESS than --page-gutter so the
     tracker card bleeds 4px past the .rails guidelines on either side
     — gives it a subtle "weight" against the surrounding text columns
     without breaking the page edge. Desktop (≥1080) has its own rule. */
  padding: var(--space-2) calc(var(--page-gutter) - 8px) var(--space-9);
  display: flex;
  justify-content: center;
}


/* ---------- 10. BUTTONS ---------- */

.btn {
  --btn-bg: var(--accent);
  --btn-fg: var(--color-white);

  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2); /* 8 */
  padding: 12px 20px;
  border-radius: var(--radius-pill);
  font-family: var(--font-body);
  font-weight: var(--fw-extrabold);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-button);
  letter-spacing: var(--tracking-tight);
  white-space: nowrap;
  background: var(--btn-bg);
  color: var(--btn-fg);
  cursor: pointer;
  /* Soft ease-out on scale + brightness so the hover settles rather
     than snaps; opacity kept short for any code that fades the button
     in/out separately from hover.
     Uses the individual `scale` property (NOT `transform: scale(...)`)
     because .hero .btn has an entrance animation with fill-mode:both
     that pins `transform: translateY(0)` permanently — that would
     override any `transform: scale(...)` we tried to set on hover.
     The individual `scale` property composes independently of
     `transform`, so both can coexist. */
  transition:
    scale      220ms cubic-bezier(0.22, 1, 0.36, 1),
    filter     200ms cubic-bezier(0.22, 1, 0.36, 1),
    background 200ms ease,
    opacity    120ms ease;
  border: 0;
}
/* Subtle "acknowledge" hover — a hair smaller, a hair darker. Click
   pushes it further (see :active below), giving a continuous feel. */
.btn:hover {
  scale: 0.99;
  filter: brightness(0.96);
}
.btn:active { scale: 0.975; }
/* Keyboard focus — 2px blue ring with 2px offset reads on cream,
   white, and blue surfaces the button lands on across the page. */
.btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.btn--primary {
  --btn-bg: var(--color-blue-600);
  --btn-fg: var(--color-white);
}
.btn--secondary {
  --btn-bg: var(--color-cream-200);
  --btn-fg: var(--color-ink-900);
}
.btn--secondary:hover {
  opacity: 1;
  --btn-bg: var(--color-cream-300);
}
.btn--light {
  --btn-bg: var(--color-white);
  --btn-fg: var(--color-ink-900);
}

.btn__arrow {
  display: inline-flex;
  width: 13px;
  height: 10px;
  color: var(--btn-fg);
  transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.btn:hover .btn__arrow { transform: translateX(4px); }
.btn__arrow svg {
  width: 100%;
  height: 100%;
}

/* ---------- 11. SVG SIZE HELPERS ---------- */

.icon-dot { width: 16px; height: 16px; }


/* ---------- 12. WORLD MAP ---------- */

/* Equirectangular world map — 2:1 is the projection's native ratio
   (longitude 360° wide, latitude 180° tall). Rendered by worldmap.js. */
.worldmap {
  position: relative;
  /* Block default — fills the parent's content area minus any margins.
     `width: 100%` would ignore margins and overflow when an inset is
     applied (see .where-we-go .worldmap margin-inline). */
  aspect-ratio: 2 / 1;
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--bg);
}
.worldmap__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* Popup overlay — DOM siblings of the canvas, positioned in canvas px.
   Each popup is a stack (pill on top, video card below) that scales
   from 0 → 1 with a bouncy genie pop on entrance, holds, then squeezes
   back into the anchor on exit. */
.worldmap__popups {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.worldmap__popup {
  position: absolute;
  width: 0;
  height: 0;
}

/* The animated element. translate(-50%, -50%) centers the stack on the
   location anchor so the genie scale-from-zero collapses to that exact
   point. Linear timing lets the keyframes do all the bounce shaping. */
.worldmap__popup-stack {
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  opacity: 0;
  transform: translate(-50%, -50%) scale(0);
  transform-origin: 50% 50%;
  animation: worldmap-genie var(--popup-life, 5s) linear forwards;
  will-change: transform, opacity;
}

.worldmap__popup-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 9px 4px 7px;
  background: var(--color-blue-600);
  border: 0.5px solid rgba(0, 82, 233, 0.45);
  border-radius: var(--radius-pill);
  color: var(--color-white);
  font-family: var(--font-body);
  font-weight: var(--fw-bold);
  font-size: 11px;
  letter-spacing: 0.04em;
  line-height: 1;
  text-transform: uppercase;
  white-space: nowrap;
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18);
}
.worldmap__popup-pill-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--color-white);
  box-shadow: 0 0 5px rgba(255, 255, 255, 0.85);
  animation: worldmap-pill-dot 1.4s ease-in-out infinite;
}
.worldmap__popup-pill-name {
  display: inline-block;
}

.worldmap__popup-card {
  width: 84px;
  height: 58px;
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: #000;
  box-shadow:
    0 10px 22px rgba(0, 0, 0, 0.18),
    0 0 0 0.5px rgba(0, 0, 0, 0.08);
}
.worldmap__popup-video {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
}

/* Genie pop: scale-from-zero with bouncy overshoot on enter, hold,
   then anticipation + squeeze back into the anchor on exit. */
@keyframes worldmap-genie {
  0%    { opacity: 0; transform: translate(-50%, -50%) scale(0);    }
  2.5%  { opacity: 1;                                                }
  6%    { transform: translate(-50%, -50%) scale(1.09);             }
  10%   { transform: translate(-50%, -50%) scale(0.97);             }
  14%   { transform: translate(-50%, -50%) scale(1.02);             }
  18%   { transform: translate(-50%, -50%) scale(1);                }
  92%   { opacity: 1; transform: translate(-50%, -50%) scale(1);    }
  94%   { transform: translate(-50%, -50%) scale(1.04);             }
  97%   { transform: translate(-50%, -50%) scale(0.55);             }
  99.5% { opacity: 1; transform: translate(-50%, -50%) scale(0.08); }
  100%  { opacity: 0; transform: translate(-50%, -50%) scale(0);    }
}

@keyframes worldmap-pill-dot {
  0%, 100% { opacity: 1;    transform: scale(1);    }
  50%      { opacity: 0.55; transform: scale(0.82); }
}

/* Reduced motion: skip the bounce — fade in/out at full size. */
@media (prefers-reduced-motion: reduce) {
  .worldmap__popup-stack {
    animation-name: worldmap-genie-reduced;
  }
  .worldmap__popup-pill-dot {
    animation: none;
  }
}
@keyframes worldmap-genie-reduced {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(1); }
  12%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  88%  { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
}

/* Interactive hover label — shown above the hovered country dot in place
   of the auto-cycling video popups. Plain white text on the map; no
   capsule. Toggled by the .is-visible class. */
.worldmap__hover-pill {
  position: absolute;
  color: var(--text-strong);
  font-family: var(--font-body);
  font-weight: var(--fw-bold);
  font-size: 11px;
  letter-spacing: 0.04em;
  line-height: 1;
  text-transform: uppercase;
  white-space: nowrap;
  text-shadow: 0 1px 3px rgba(255, 255, 255, 0.85);
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, calc(-100% - 10px)) scale(0.92);
  transition:
    opacity 0.16s ease-out,
    transform 0.18s cubic-bezier(0.34, 1.5, 0.64, 1),
    left 0.16s cubic-bezier(0.16, 1, 0.3, 1),
    top 0.16s cubic-bezier(0.16, 1, 0.3, 1);
}
.worldmap__hover-pill.is-visible {
  opacity: 1;
  transform: translate(-50%, calc(-100% - 10px)) scale(1);
}
@media (prefers-reduced-motion: reduce) {
  .worldmap__hover-pill {
    transition: opacity 0.12s linear;
    transform: translate(-50%, calc(-100% - 10px));
  }
  .worldmap__hover-pill.is-visible {
    transform: translate(-50%, calc(-100% - 10px));
  }
}

/* Where We Go — section frame (matches the campaign pattern: gutters,
   title, lede, then the visual). */
.where-we-go {
  padding: var(--space-8) var(--page-gutter) var(--space-9);
}
/* Typography matches the Africa Fund title — Plus Jakarta Sans Bold
   (not Extrabold), tighter line-height. Color stays white since this
   section sits on the dark page bg, not the green Africa Fund block. */
.where-we-go__title {
  font-family: var(--font-display);
  font-weight: var(--fw-bold);
  font-size: var(--fs-display-lg);
  line-height: 0.96;
  letter-spacing: var(--tracking-tightest);
  color: var(--text-strong);
  margin-bottom: var(--space-5);
  width: 70%;
}
.where-we-go__lede {
  font-size: var(--fs-body-md);
  line-height: var(--lh-body);
  letter-spacing: var(--tracking-default);
  color: var(--text-strong);
  margin-bottom: var(--space-8);
}
.where-we-go__lede-muted {
  color: var(--text-card-lede);
}
.where-we-go .worldmap {
  margin-bottom: var(--space-7);
  /* Inset just enough that the .rails hairlines (z-index: -1 behind
     the map's bg) stay visible to either side of the canvas instead
     of being hidden by the map's solid --bg fill. */
  margin-inline: var(--space-2);
}

/* Fund header — names the fund those listed countries belong to.
   Quiet white label, no icon. Reveals in sync with the country-list
   cascade (same easing/duration as .country-list__item, no per-item
   delay) so it lands together with the first row.
   Repeats above each fund's own list as more funds launch. */
.country-list__fund {
  font-family: var(--font-body);
  font-weight: var(--fw-bold);
  font-size: var(--fs-body-sm);
  letter-spacing: var(--tracking-tighter);
  line-height: var(--lh-flat);
  color: var(--text-strong);
  margin: 0 0 var(--space-5);

  opacity: 0;
  transform: translateY(6px);
  transition:
    opacity 520ms var(--ease-out-soft),
    transform 520ms var(--ease-out-soft);
}
.country-list__fund.is-revealed {
  opacity: 1;
  transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
  .country-list__fund {
    transition: opacity 220ms linear;
    transform: none;
  }
}

/* Country list — two-column hairline grid with indexed rows. CSS
   counter generates the zero-padded number, removing the need for
   per-item template logic. Brutalist/Linear-esque: small uppercase
   type, tight tracking, faint dividers, no decorative glow. Each
   row reveals on scroll-in with a staggered delay driven by --i. */
.country-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: var(--space-5);
  row-gap: 0;
  border-top: 1px solid var(--border-fainter);
}
.country-list__item {
  display: flex;
  align-items: center;
  gap: 10px;
  /* Left padding fills the space the numeric counter used to take so
     the dot+name still sit indented from the column edge instead of
     jamming against it. */
  padding: 7px 0 7px var(--space-4);
  border-bottom: 1px solid var(--border-fainter);
  font-family: var(--font-body);
  font-weight: var(--fw-semibold);
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-strong);
  line-height: 1;
  opacity: 0;
  transform: translateY(6px);
  transition:
    opacity 520ms var(--ease-out-soft) calc(var(--i, 0) * 32ms),
    transform 520ms var(--ease-out-soft) calc(var(--i, 0) * 32ms);
}
.country-list__dot {
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--accent);
  flex-shrink: 0;
  /* Ambient breathing pulse — asymmetric keyframe (quick rise, slow
     decay) feels organic. Each row sets its own --pulse-delay in the
     template from a hand-shuffled array, so the 20 dots scatter
     across the 4s cycle without any detectable linear pattern. */
  animation: country-dot-breathe 2s ease-in-out infinite;
  animation-delay: var(--pulse-delay, 0s);
}

@keyframes country-dot-breathe {
  0%   { opacity: 0.60; transform: scale(0.89); }
  35%  { opacity: 1.00; transform: scale(1.00); }
  65%  { opacity: 0.85; transform: scale(0.95); }
  100% { opacity: 0.60; transform: scale(0.92); }
}

@media (prefers-reduced-motion: reduce) {
  .country-list__dot {
    animation: none;
    opacity: 1;
  }
}
.country-list__name {
  flex: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.country-list.is-revealed .country-list__item {
  opacity: 1;
  transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
  .country-list__item {
    transition: opacity 220ms linear;
    transform: none;
  }
}


/* ---------- 13. HERO ANIMATIONS ---------- */

/* Initial state for all entrance targets: hidden + reset transform.
   CSS is in <head> so this paints synchronously — no FOUC.
   Note: .hero__lede uses a mask sweep instead of opacity (see below). */
.hero__image,
.hero__title-line,
.hero__title-play,
.hero-pill,
.hero .btn {
  opacity: 0;
  animation-fill-mode: both;
  animation-timing-function: var(--ease-out-soft);
}

/* Mobile fast-fade: the image IS the section's background and the
   gradient scrim is what makes the overlaid title readable. Both
   fade in together at the same fast pace so the framing snaps into
   place quickly on small screens. Desktop overrides the image's
   timing back to the slower entrance further down (in the
   ≥1080px block) since the image lives in its own grid cell there
   and isn't the framing-critical element. The ::after gradient is
   already display:none on desktop so no override needed for it. */
.hero__image,
.hero::after                       { animation: hero-rise-up 1580ms 120ms   both var(--ease-out-soft); }
.hero__title-line:nth-child(1)     { animation: hero-rise-sm 480ms 260ms both var(--ease-out-soft); }
.hero__title-line:nth-child(2)     { animation: hero-rise-sm 480ms 380ms both var(--ease-out-soft); }
.hero__title-play                  { animation: hero-fade    260ms 560ms both var(--ease-out-soft); }
.hero-pill                         { animation: hero-rise-sm 600ms 440ms both var(--ease-out-soft); }
.hero .btn                         { animation: hero-rise-sm 680ms 560ms both var(--ease-out-soft); }

/* Pill children crystallize in as the pill itself is rising — not
   after it settles. Initial state: hidden + blurred + slight Y-rise.
   Each child fades in, clears blur, and settles — a sleek "data
   crystallizes into focus" cascade that reads in lockstep with the
   pill's arrival.
   Pill entrance is hero-rise-sm 600ms @440ms; the first child kicks
   off at 500ms so the contents land *with* the pill rather than
   loading after it. The dot has its own chained animation (see §6b)
   so the pulse takes over once the entrance finishes. */
.hero-pill__live,
.hero-pill__fund-name,
.hero-pill__amount-value,
.hero-pill__goal {
  opacity: 0;
  filter: blur(6px);
  transform: translateY(3px);
  animation: pill-crystallize 540ms var(--ease-out-soft) both;
}
.hero-pill__live         { animation-delay: 500ms; }
.hero-pill__fund-name    { animation-delay: 580ms; }
/* dot is at 660ms — see §6b */
.hero-pill__amount-value { animation-delay: 740ms; }
.hero-pill__goal         { animation-delay: 820ms; }

@keyframes pill-crystallize {
  to {
    opacity: 1;
    filter: blur(0);
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero-pill__live,
  .hero-pill__fund-name,
  .hero-pill__amount-value,
  .hero-pill__goal {
    opacity: 1;
    filter: none;
    transform: none;
    animation: none;
  }
}

/* Opacity-only fade for the hero image. The mobile zoom (--image-scale)
   and the parallax/collapse drift are owned by the standalone `scale`
   and `translate` properties on .hero__image so they can be composed
   with JS-driven scroll values without fighting the keyframe. */
@keyframes hero-rise    { from { opacity: 0; } to { opacity: 1; } }
/* Same as hero-rise but adds a subtle upward translate. Uses the
   individual `translate` property (not `transform: translateY()`) so
   it composes with the .hero__image's existing `transform: scale(...)`
   on mobile — `transform: translateY()` would clobber the scale.
   The rise distance is parameterized via --hero-rise-distance so
   breakpoints can dial in their own values (mobile default 44px,
   desktop override to ~10px for a subtler lift — see ≥1080px block). */
@keyframes hero-rise-up { from { opacity: 0; translate: 0 var(--hero-rise-distance, 24px); } to { opacity: 1; translate: 0 0; } }
@keyframes hero-rise-sm { from { opacity: 0; transform: translateY(8px);  } to { opacity: 1; transform: translateY(0); } }
@keyframes hero-fade    { from { opacity: 0; } to { opacity: 1; } }

/* Lede top-down sweep — mask gradient (~0.5x element height fade zone)
   slides from below the text upward, so each visual line fades in as the
   edge passes through it. Reads as "loading line by line, top to bottom". */
.hero__lede {
  -webkit-mask-image: linear-gradient(180deg, #000 30%, transparent 70%);
          mask-image: linear-gradient(180deg, #000 30%, transparent 70%);
  -webkit-mask-size: 100% 250%;
          mask-size: 100% 250%;
  -webkit-mask-position: 0% 100%;
          mask-position: 0% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  animation: lede-reveal 1800ms 320ms forwards var(--ease-out-soft);
}

@keyframes lede-reveal {
  to {
    -webkit-mask-position: 0% 0%;
            mask-position: 0% 0%;
  }
}

/* Play SVG write-on — centerline mask paths reveal the filled letters
   as their stroke draws. pathLength="100" normalizes timing per letter.
   The SVG markup carries fixed width="98" height="55" attributes, so
   target it directly through .hero__title-play rather than relying on
   a class on the <svg> element — that way the artwork fills the
   em-sized container box at every breakpoint instead of rendering at
   its natural 98×55 pixel size in the corner of the box. */
.hero__title-play svg {
  display: block;
  width: 100%;
  height: 100%;
}

.play-mask__p,
.play-mask__l,
.play-mask__a,
.play-mask__y {
  stroke-dasharray: 100 100;
  stroke-dashoffset: 100;
}

.play-mask__p { animation: play-draw var(--play-draw-duration) var(--play-start-delay)                                       forwards var(--ease-in-out-snap); }
.play-mask__l { animation: play-draw var(--play-draw-duration) calc(var(--play-start-delay) + 1 * var(--play-stagger)) forwards var(--ease-in-out-snap); }
.play-mask__a { animation: play-draw var(--play-draw-duration) calc(var(--play-start-delay) + 2 * var(--play-stagger)) forwards var(--ease-in-out-snap); }
.play-mask__y { animation: play-draw var(--play-draw-duration) calc(var(--play-start-delay) + 3 * var(--play-stagger)) forwards var(--ease-in-out-snap); }

@keyframes play-draw {
  to { stroke-dashoffset: 0; }
}

/* Reduced motion: full bypass. Everything visible immediately, no
   animations run. */
@media (prefers-reduced-motion: reduce) {
  .hero__image,
  .hero__title-line,
  .hero__title-play,
  .hero-pill,
  .hero .btn {
    opacity: 1;
    transform: none;
    animation: none;
  }
  .hero__lede {
    -webkit-mask-image: none;
            mask-image: none;
    animation: none;
  }
  .play-mask__p,
  .play-mask__l,
  .play-mask__a,
  .play-mask__y {
    stroke-dashoffset: 0;
    animation: none;
  }
}


/* ---------- 14. QUOTE ANIMATIONS ---------- */

/* Initial hidden state — paints synchronously, no FOUC.
   Follows the hero's main entrance (peaks 80–560ms) with a 700ms base delay
   so it reads as a continuation, not a competing beat. */
.quote__mark,
.quote__text,
.quote__image,
.quote__attribution {
  opacity: 0;
  animation-fill-mode: both;
  animation-timing-function: var(--ease-out-soft);
}

.quote__mark:not(.quote__mark--close) { animation: hero-rise-sm       480ms 700ms  both var(--ease-out-soft); }
.quote__text:nth-of-type(1)           { animation: hero-rise-sm       480ms 820ms  both var(--ease-out-soft); }
.quote__image                         { animation: hero-rise-sm       480ms 940ms  both var(--ease-out-soft); }
.quote__text:nth-of-type(2)           { animation: hero-rise-sm       480ms 1060ms both var(--ease-out-soft); }
.quote__attribution                   { animation: hero-rise-sm       480ms 1180ms both var(--ease-out-soft); }
.quote__mark--close                   { animation: quote-rise-flipped 480ms 1300ms both var(--ease-out-soft); }

/* Dedicated keyframe preserves the 180° rotation through the rise,
   so the closing mark doesn't un-flip mid-animation. */
@keyframes quote-rise-flipped {
  from { opacity: 0; transform: translateY(8px) rotate(180deg); }
  to   { opacity: 1; transform: translateY(0)   rotate(180deg); }
}

@media (prefers-reduced-motion: reduce) {
  .quote__mark,
  .quote__text,
  .quote__image,
  .quote__attribution {
    opacity: 1;
    animation: none;
  }
  .quote__mark--close {
    transform: rotate(180deg);
  }
}


/* ---------- 14b. FOOTER ----------
   Linear-esque: roomy vertical stack inside the rails, with the
   wordmark and quiet legal copy. Lives under a standard .divider so
   the framed page closes cleanly. */
.site-footer {
  padding: 0 var(--page-gutter) var(--space-9);
  display: flex;
  flex-direction: column;
  gap: var(--space-9);
}

.site-footer__mark {
  display: block;
  width: 148px;
  height: auto;
  opacity: 0.9;
}

.site-footer__legal {
  font-family: var(--font-body);
  font-weight: var(--fw-medium);
  font-size: 12px;
  line-height: 1.6;
  letter-spacing: var(--tracking-default);
  /* Use subtle (ink-600) not faint (cream-400) — small body copy needs
     more contrast than the decorative numbered prefixes that read text-faint. */
  color: var(--text-subtle);
  max-width: 320px;
}
.site-footer__legal strong {
  color: var(--text-muted);
  font-weight: var(--fw-semibold);
}

/* ---------- 15. WEBGL REVEAL ---------- */

/* Full-viewport canvas that renders the reveal effect for every
   img[data-webgl] in sync with the DOM. `has-webgl` is set by JS only
   when WebGL2 is supported and motion isn't reduced — otherwise images
   render normally with their existing CSS animations.
   Canvas sits above the body background (z 0) and the page content sits
   above the canvas (z 1); tagged images are opacity 0, so the
   shader-rendered image shows through their footprint. */
#webgl {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

html.has-webgl .page { z-index: 1; }

html.has-webgl img[data-webgl] {
  opacity: 0;
  animation: none;
}


/* ============================================================
   16. RESPONSIVE — mobile → desktop expansion
   Three additive breakpoints, mobile-first. Each :root block
   re-declares layout tokens; the `.rails` hairlines (which read
   --page-gutter) breathe automatically. Typography scales fluidly
   via clamp() in the primitive tokens, so font-size doesn't need
   to be redeclared per breakpoint.
     • 560px — release the 390px cap; relax hardcoded widths
     • 840px — campaign image cluster absolute → grid; multi-col
                country list + tracker body; centered footer
     • 1080px — full desktop: 2-col hero, 2-col campaign (tracker
                spans full), 2-col where-we-go; donate CTA at rest
   ============================================================ */


/* ---------- 16a. ≥560px — release the cage ---------- */

@media (min-width: 560px) {
  :root {
    --page-gutter: var(--space-8);   /* 32px */
    --content-max: 520px;
  }

  /* Strip hardcoded element widths to character-based caps that
     scale with the now-clamped display type. */
  .hero__title       { width: auto; max-width: 22ch; height: auto; }
  .hero__lede        { width: auto; max-width: 56ch; }
  .quote__text       { width: auto; max-width: 40ch; }
  .campaign__title   { width: auto; max-width: 12ch; }
  .campaign__lede    { width: auto; max-width: 52ch; }
  .where-we-go__title{ width: auto; max-width: 16ch; }
}


/* ---------- 16b. ≥840px — campaign images grid, multi-col lists ---------- */

@media (min-width: 840px) {
  :root {
    --page-gutter:   40px;
    --content-max:   760px;
    --section-pad-y: 48px;
    --col-gap:       var(--space-8);
    --hero-image-h:  320px;
  }

  /* More vertical breathing room between sections at tablet+. The
     hero stays a uniform-padded overlay card here (the desktop ≥1080
     block swaps it for the 2-col grid layout). */
  .hero      { padding: var(--space-7); }
  .quote     { padding: var(--section-pad-y) var(--page-gutter); }
  .campaign  { padding: var(--section-pad-y) var(--page-gutter); }
  .where-we-go { padding: var(--section-pad-y) var(--page-gutter); }
  .site-footer { padding: 0 var(--page-gutter) var(--section-pad-y); }

  /* Campaign image cluster: from absolute → 12-col grid.
     `align-self: end/start` recreates the staggered Y-offset (left
     low, right high) within a single grid row. The parallax JS
     keeps writing translate3d() — composes cleanly over static
     positioning, so the gentle drift continues unmodified. */
  .campaign__images {
    position: relative;
    height: auto;
    max-width: none;      /* unset the mobile cap — grid spans the section */
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    gap: var(--space-5);
    margin-bottom: var(--space-8);
  }
  .campaign__image {
    position: static;
    width: 100%;
    height: auto;
  }
  .campaign__image--left {
    grid-column: 1 / span 5;
    grid-row: 1;
    align-self: end;
    aspect-ratio: 198 / 297;     /* matches new ANGOLA portrait crop */
    max-width: 280px;
  }
  .campaign__image--right {
    grid-column: 6 / span 7;
    grid-row: 1;
    align-self: start;
    aspect-ratio: 211 / 264;     /* matches new SPA portrait crop */
    max-width: 380px;
  }

  /* Country list: 2 → 3 columns. The reveal cascade uses inline
     --i delays, so the stagger reads correctly across either grid. */
  .country-list { grid-template-columns: repeat(3, 1fr); column-gap: var(--space-6); }
  .country-list__item { padding: 9px 0 9px var(--space-3); }

  /* Footer: roomier, centered. */
  .site-footer { align-items: center; text-align: center; }
  .site-footer__legal { max-width: 56ch; }
}


/* ---------- 16c. ≥1080px — full desktop layouts ---------- */

@media (min-width: 1080px) {
  :root {
    --page-gutter:   64px;
    --content-max:   1200px;
    --section-pad-y: 64px;
    --col-gap:       72px;
    --hero-image-h:  460px;
  }

  /* Header: donate CTA visible at rest; logo a touch larger; the
     .is-compact scroll trigger still fires (driven by hero CTA via
     header.js), but at desktop it only nudges the logo — the CTA is
     already in place, so there's no slide-in. The cream surface fills
     in at rest here since the desktop hero doesn't have a photo for
     the header to sit on top of. Solid color, no backdrop blur. */
  .site-header {
    padding: var(--space-4) var(--page-gutter);
    background: var(--color-cream-50);
  }
  .site-header__logo {
    transform: translateY(4px) scale(1.15);
  }
  .site-header.is-scrolled .site-header__logo {
    transform: translateY(0) scale(1);
  }
  .site-header__cta {
    opacity: 1;
    transform: translateX(0);
    pointer-events: auto;
    visibility: visible;
  }
  .site-header__cta .btn {
    padding: 11px 22px;
    font-size: 14px;
  }

  /* Hero: swap the mobile overlay treatment for a 2-col split. The
     photo moves out of the absolute-background role and into the
     right grid column; the gradient overlay turns off; copy/pill/
     button stack in the left column. */
  .hero {
    margin: 0 auto;
    padding: var(--space-7) var(--page-gutter) var(--section-pad-y);
    border-radius: 0;
    overflow: visible;
    min-height: auto;
    /* Undo the mobile overlay-card sizing — desktop is a grid, no
       aspect-ratio constraint, and content fills its column naturally
       (no justify-end). */
    aspect-ratio: auto;
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    column-gap: var(--col-gap);
    align-items: center;
    justify-content: normal;
  }
  .hero::after { display: none; }
  .hero__image {
    position: static;
    grid-column: 2;
    grid-row: 1;
    width: 100%;
    height: auto;
    aspect-ratio: 4 / 5;
    max-height: 720px;
    border-radius: var(--radius-sm);
    object-fit: cover;
    object-position: center 30%;
    align-self: stretch;
    z-index: 0;
    /* No transform on desktop — the photo lives in its own grid cell
       at natural aspect, no cropping zoom needed. JS clears any
       inline transform on desktop (see parallax.js hero IIFE) and
       this rule keeps the resting state clean. */
    transform: none;
    transform-origin: center center;
    /* Restore the slower, more cinematic entrance for desktop. On
       mobile the image is the section background and needs to snap
       into place quickly (480ms — see base rule above); on desktop
       it's a calmer composition piece and benefits from a longer fade.
       Rise distance also dials back — desktop image is in its own
       grid cell so a big lift reads as too much movement; ~10px feels
       like a settle into place. */
    --hero-rise-distance: 10px;
    animation-duration: 1560ms;
    animation-delay: 680ms;
  }
  .hero__inner {
    grid-column: 1;
    grid-row: 1;
    margin-top: 0;
    align-self: center;
    gap: var(--space-8);
    max-width: 540px;
  }
  .hero__copy {
    gap: var(--space-8);
  }
  .hero__title {
    font-size: 84px;
    line-height: 1.01;
    letter-spacing: -0.04em;
    /* Desktop pulls the title out of the photo overlay, so revert to
       theme-aware ink color from the white-pinned mobile baseline. */
    color: var(--text-strong);
  }
  .hero__lede        { color: var(--text-strong); }
  .hero__lede-muted  { color: var(--text-subtle); }
  .hero__title-play {
    /* Tuned to sit INLINE with "Access to" — i.e. the artwork's
       baseline (which lives at y=40 of the 55-unit viewBox, so 40/55
       ≈ 0.727 down the badge box) must land on the line-2 text
       baseline. With line-height 1.01 and Plus Jakarta Sans metrics,
       line-2 baseline ≈ 2.0em from title-top.

       Solving badge_top + 0.727·height = 2.0em with height = 1.3em
       (so the "Play" cap-portion is 0.945em — about 31% taller than
       the 0.72em text cap-height, matching the design): top = 1.05em.

       Mobile uses line-height 1.1 (looser) so its top values are a
       hair lower (~1.16em) for the same alignment — that's why the
       mobile values can't carry directly to desktop. */
    top: 1.2em;
    left: 4.4em;
    width: 2.05em;
    height: 1.05em;
    /* Desktop: title is over the cream page bg, so the Play artwork
       switches from the mobile sky-blue (which read on the dark photo)
       to the brand blue to match the donate button. */
    color: var(--color-blue-600);
  }
  .hero__lede { max-width: 530px; }
  /* Pill moves off the dark photo at desktop — restore the cream
     surface tone and reset the children's white-pinned colors so they
     follow the theme again. */
  .hero-pill {
    max-width: 360px;
    background: var(--color-cream-100);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  .hero-pill__label,
  .hero-pill__amount  { color: var(--text-strong); }
  .hero-pill__bracket { color: var(--color-cream-300); }
  .hero-pill__goal    { color: var(--text-faint); }
  /* Live indicator: revert from the mobile lime (#a3e635, tuned for
     the dark-photo overlay) to the cream-pill green tone. */
  .hero-pill__live-text { color: var(--color-green-live); }
  .hero-pill__dot       { background: var(--color-green-live); }
  /* Desktop donate button — hugs its contents (overrides the mobile
     full-width via align-self:start, since the flex column otherwise
     stretches children horizontally) but keeps the mobile padding so
     the touch target reads at the same scale. Extrabold per the
     desktop Figma; mobile uses Bold. */
  .hero .btn {
    width: auto;
    align-self: start;
    justify-content: center;
    padding: 20px 24px;
    font-weight: var(--fw-bold);
    letter-spacing: var(--tracking-default);
  }
  .hero .btn__arrow {
    width: 13px;
    height: 10px;
  }

  /* Quote: stays a centered editorial column, capped narrower than
     the page. The parallax JS still drives --quote-frame-w (width %)
     for the bleed-on-entry effect; we override `height` to a fluid
     desktop value so the frame doesn't stay at the 144→220px mobile
     range that parallax.js hardcodes via --quote-frame-h. */
  .quote { max-width: 880px; margin-inline: auto; }
  .quote__image { height: clamp(260px, 28vw, 460px); }

  /* Africa Fund: full-bleed blue panel containing a 2-col editorial
     grid (tag/title/lede left, photo pair right). The tracker lives
     in its own .tracker-section below (on cream). Unlike the previous
     dark theme, .campaign__hero stays a panel at desktop — it doesn't
     collapse to display:contents — so the blue background covers the
     full section width with the editorial content riding inside it. */
  .campaign {
    padding: 0 var(--page-gutter);
    margin-block: var(--section-pad-y);
    margin-bottom: -2em;
  }
  .campaign__hero {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    grid-template-areas:
      "tag     images"
      "title   images"
      "lede    images";
    column-gap: var(--col-gap);
    row-gap: var(--space-5);
    align-items: start;
    padding: var(--section-pad-y) var(--page-gutter);
    margin-bottom: 0;
  }
  .campaign__tag   { grid-area: tag;   margin-bottom: 0; }
  /* Two-class selector so this beats the mobile .campaign__hero rule.
     Title scales up at desktop to the larger Figma value while keeping
     the white-on-blue treatment. */
  .campaign__hero .campaign__title {
    grid-area: title;
    margin-bottom: 0;
    font-size: var(--fs-display-lg);
    font-weight: var(--fw-bold);     /* Plus Jakarta Sans Bold */
    color: var(--color-white);
    line-height: var(--lh-display);
    letter-spacing: -0.04em;
    max-width: 14ch;
  }
  .campaign__lede  { grid-area: lede;  margin-bottom: 0; max-width: 52ch; }
  /* Photo cluster reverts from the mobile diagonal absolute layout to
     the ≥840 12-col grid; both photos sit side-by-side with the
     staggered Y offset. */
  .campaign__hero .campaign__images {
    grid-area: images;
    height: auto;
    margin-top: 0;
    margin-bottom: 0;
    max-width: 480px;
  }
  .campaign__hero .campaign__image--left,
  .campaign__hero .campaign__image--right {
    position: static;        /* unset mobile absolute */
    width: 100%;
    height: auto;
    top: auto;
    left: auto;
  }
  .campaign__image--left  { max-width: 200px; }
  .campaign__image--right { max-width: 280px; }

  /* Tracker section: tighter gap to the campaign block above, full
     section padding below before the next section. */
  .tracker-section {
    padding: var(--space-4) var(--page-gutter) var(--section-pad-y);
  }

  /* Where We Go: stacked — title, then the full-width world map (a 2:1
     map reads far better wide than boxed beside the list), then the
     country list (3-col from the ≥840 block). */
  .where-we-go__title { max-width: 18ch; }
  .where-we-go .worldmap { margin-bottom: var(--space-9); }

  /* Footer: bigger mark, wider legal, tighter gap against the
     larger desktop type rhythm. */
  .site-footer        { gap: var(--space-8); }
  .site-footer__mark  { width: 200px; }
  .site-footer__legal { max-width: 64ch; font-size: 13px; }
}
