/* ==========================================================================
   Barefoot Conservation — the Week 1 → Week 12 timeline, and the layer below it
   Loaded AFTER site.css, which owns the shared component grammar and imports
   the tokens (CLAUDE.md rule 16 — the token block is never duplicated).

   THIS FILE DOES NOT OWN "THE TIMELINE LAYER ONLY". It said so for a week and
   that sentence cost a broken deploy: it also owns the ENTIRE below-the-scroll
   layer of the landing page — `.band-grid`, `.card--media`, `.empty--media`,
   the whole `.cta-full` family — which is why removing this stylesheet from a
   page strips the styling off nineteen classes that have nothing to do with
   the scroll. Both `/` and `/expedition` load it.

   The timeline's own layer: the scroll track, the stages, the expedition chart
   (map + route + boat + waypoints), and the fallback modes. The background is
   a top-down aerial photograph (`.map__photo` below, and
   views/partials/chart-map.ejs) — the hand-inked SVG chart it replaced is
   gone. Stage copy sits in a left column over a scrim (bottom on mobile).
   ========================================================================== */

/* ------------------------------------------------------- the scroll track -- */
.weeks {
  position: relative;
  /* --week-track is a token: the whole timeline is this tall, and the pinned
     viewport inside it is what the reader actually sees. */
  height: var(--week-track);
  background: var(--deep-900);
  /* Belt and braces on top of the .weeks__back restructure. Without a stacking
     context here, `z-index` on the children resolved in the ROOT one, so the
     map painted over the *background* of the next section while that section's
     own `.shell` (z-index 1, later in the DOM) still painted over the map —
     which is how the Expeditions heading ended up as dark ink on a dark chart.
     Isolating means nothing in the timeline can outrank a later section again,
     whatever z-index it claims. */
  isolation: isolate;
}

/* The sticky header is in flow, so the timeline used to begin 69px down the page:
   the first 69px of scrolling moved the whole frame before the scrub engaged,
   which reads as the page "slipping" before the timeline starts. Beginning the
   timeline at the document top removes that dead travel. The header is solid
   --deep-900 and simply overlays the map's top edge — which is what it does at
   every other scroll position anyway. */
.weeks { margin-top: calc(var(--hdr) * -1); }

/* The single sticky element behind the copy. It owns the whole frame height
   and every layer inside is absolutely positioned against it, so they
   can neither drift apart nor outlive the pin. Do NOT give the layers back
   their own sticky positioning — see the note in home.ejs for what that cost.

   THE FRAME IS SIZED IN `svh`, NOT `vh`, AND THAT IS LOAD-BEARING ON PHONES.
   `vh` resolves to the LARGE viewport — the height the page would have with
   the browser's URL bar retracted — so a 100vh pinned frame on a phone is
   taller than what you can actually see the moment the page loads with the
   bar showing. The stage copy is anchored to the bottom of this frame, so the
   part that fell off the bottom was the CTA on every single stage. `svh` is
   the SMALL viewport (bar showing), i.e. the worst case, so the frame always
   fits. It is deliberately not `dvh`: dvh changes as the bar hides and shows,
   which would resize a pinned, scrubbed frame mid-scroll and re-run every
   ScrollTrigger measurement. The `vh` line above it is the fallback for
   engines without svh and keeps today's behaviour there.
   All three values below must change together — the pin's negative margin has
   to match the backdrop's height exactly or the two stop releasing on the same
   pixel (see the 900px overhang in the session log). */
.weeks__back {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100svh;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
}
.weeks__back > * { position: absolute; inset: 0; }

.weeks__pin {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100svh;
  /* Pulled back over .weeks__back so the pair contributes one frame height
     between them and both engage and release on exactly the same pixel. */
  margin-top: -100vh;
  margin-top: -100svh;
  display: grid;
  grid-template-rows: 1fr;
  justify-items: center;
  align-items: stretch;   /* the stage box must FILL the padded area, not hug
                             its content — otherwise `inset:0` on an absolutely
                             positioned .stage collapses to a zero-height line
                             and tall copy overflows symmetrically past the
                             header instead of centring inside the viewport. */
  overflow: hidden;
  z-index: 1;
}

/* The base under the map. Only seen for the instant before the photo paints,
   in the 8vh strip the mobile map shift leaves at the bottom, and behind the
   chart in the stacked fallback modes. */
.weeks__sky { background-color: var(--deep-800); }

/* ------------------------------------------------------- the map layer -- */
/* .weeks__chart wraps BOTH the photo and the SVG so any transform applies to
   the pair and they cannot drift apart (see the geometry contract in
   chart-map.ejs — their alignment is load-bearing). */
.weeks__chart { pointer-events: none; }
.weeks__chart > * { position: absolute; inset: 0; }

/* The satellite-style aerial. `cover` + `center` on a SQUARE source crops
   identically to the SVG's xMidYMid slice — that identity is what lets
   viewBox coordinates address image fractions directly. */
/* Sized by DPR, not by CSS width alone: a 1280px viewport at dpr 2 needs
   ~2560 physical pixels of cover, so a width-only media query served a
   1400 and upscaled it 1.8×. The plain url() is the fallback for engines
   without image-set. Source is 4096² if a larger step is ever needed. */
.map__photo {
  background-image: url("/assets/expedition-map-1400.webp");
  background-image: -webkit-image-set(
    url("/assets/expedition-map-1400.webp") 1x,
    url("/assets/expedition-map-2200.webp") 2x);
  background-image: image-set(
    url("/assets/expedition-map-1400.webp") 1x,
    url("/assets/expedition-map-2200.webp") 2x);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  /* The source aerial was generated dark and moody. A translucent wash on top
     cannot make it sunny — it only veils it — so the daylight is put in here,
     on the photograph itself. Static, so it rasterises once rather than
     recompositing with the per-frame tint on the sibling layer. */
  filter: brightness(1.34) saturate(1.14) contrast(.94);
}
@media (max-width: 900px) {
  .map__photo {
    background-image: url("/assets/expedition-map-900.webp");
    background-image: -webkit-image-set(
      url("/assets/expedition-map-900.webp") 1x,
      url("/assets/expedition-map-1400.webp") 2x);
    background-image: image-set(
      url("/assets/expedition-map-900.webp") 1x,
      url("/assets/expedition-map-1400.webp") 2x);
  }
}
@media (min-width: 1500px) {
  .map__photo {
    background-image: url("/assets/expedition-map-2200.webp");
    background-image: -webkit-image-set(url("/assets/expedition-map-2200.webp") 1x);
    background-image: image-set(url("/assets/expedition-map-2200.webp") 1x);
  }
}

/* Time-of-day wash over the photo. main.js writes both the colour and the
   alpha every frame; it stays low enough that the map still reads as a place,
   and closes at --deep-900 / high alpha so the timeline hands off to the dark
   footer with no seam. Plain alpha, never a blend mode (Phase 3 note: a
   blended layer here would force a repaint of the scrolling container). */
.map__tint {
  background-color: rgba(255, 246, 214, .10);
  transition: background-color .12s linear;
}
/* Closing fade into the footer, driven only over the last 10% of scroll so
   the map keeps its daylight the rest of the way. */
.map__fade {
  background: var(--deep-900);
  opacity: 0;
  pointer-events: none;
}

.chart { width: 100%; height: 100%; display: block; }

/* Phones anchor the copy to the bottom of the frame, so the map's action band
   shifts up out of its way. Applied to the wrapper so photo and SVG move
   together. */
@media (max-width: 900px) {
  .weeks__chart { transform: translateY(-8vh); }
}

.chart text { font-family: var(--font-mono); font-weight: 500; }
.chart__note { fill: rgba(244, 237, 225, .34); font-size: 12px; letter-spacing: .18em; }
.chart__note--mobile { display: none; }
@media (max-width: 900px) {
  .chart__note--desktop { display: none; }
  .chart__note--mobile { display: block; font-size: 15px; }
}

/* The planned voyage (always visible, finely dotted) and the logged track
   (drawn behind the boat via stroke-dashoffset — main.js owns the dash
   values). Deliberately faint: the map is scenery, and the route crosses
   itself three times as the boat runs out and back from base. */
.chart__route {
  fill: none;
  stroke: rgba(244, 237, 225, .16);
  stroke-width: 1.2;
  stroke-dasharray: 1 13;
  stroke-linecap: round;
}
.chart__trace {
  fill: none;
  stroke: var(--seagrass);
  stroke-width: 1.4;
  stroke-linecap: round;
  opacity: .38;
  visibility: hidden;          /* until the engine sets its dash lengths */
}
.weeks--live .chart__trace { visibility: visible; }

/* ------------------------------------------------------------- waypoints -- */
/* r is set by main.js from the marker scale (~5px across at any viewport);
   only the paint lives here. */
.wp__dot {
  fill: var(--deep-900);
  stroke: rgba(244, 237, 225, .72);
  stroke-width: 1.1;
  transition: fill var(--t-micro) var(--ease);
}
.wp.is-logged .wp__dot { fill: var(--seagrass); }
.wp.is-here .wp__dot { fill: var(--ochre); stroke: var(--shell); }

/* The epilogue waypoint is not a boat position — the boat has left — so it
   carries a bubble and the radiating data lines, but no vessel marker. */
.wp[data-offroute] .wp__dot,
.wp[data-offroute] .wp__pulse { display: none; }

.wp__pulse {
  fill: none;
  stroke: var(--seagrass-bright);
  stroke-width: 2;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}
.wp.is-here .wp__pulse { animation: wp-pulse 2.2s var(--ease) infinite; }
@keyframes wp-pulse {
  0%   { opacity: .85; transform: scale(1); }
  70%  { opacity: 0;   transform: scale(3.4); }
  100% { opacity: 0;   transform: scale(3.4); }
}

.wp__bubble {
  opacity: 0;
  transform: translateY(8px) scale(.9);
  transform-box: fill-box;
  transform-origin: 50% 100%;
  transition: opacity .45s var(--ease), transform .45s var(--ease);
}
.wp--flip .wp__bubble { transform: translateY(-8px) scale(.9); transform-origin: 50% 0%; }
.wp.is-here .wp__bubble { opacity: 1; transform: none; }

/* Strokes are in viewBox units, so they scale with the marker. Thinned with
   it — 1.5 on the old 52-unit plate is proportionally much heavier on a
   32-unit one. */
/* The label and its icon sit NAKED on the map — no plate, no tail. A plate has
   to be measured to fit its own text, which means the text can disagree with
   it and the disagreement shows as copy running out of its own box; there is
   nothing left here to overflow. Legibility comes from a dark halo stroked
   behind the glyphs plus a shadow on the group, which is how map labels have
   always sat over imagery. main.js sets stroke-width from the font size so the
   halo scales with the marker; paint-order puts it BEHIND the fill, so the
   letterforms stay sharp. (The rule itself lives with the rest of .wp__label
   below.) */
.wp__bubble { filter: drop-shadow(0 1px 2px rgba(6, 21, 18, .92)); }

/* THE ICON COLOUR IS NOT SET HERE, AND CANNOT BE. The waypoint art is a raster
   in an SVG <image> (`.wp__art`), and CSS cannot recolour those pixels — the
   colour is baked into `public/assets/wp/*.webp`.

   They were a flat --seagrass-bright fill plus an alpha channel, and green
   over turquoise water is close to invisible: the map's brightest pixels are
   literally 255,255,255, and the icons all but vanished there. They are now
   --shell, the same cream as the label, over a dilated --deep-900 outline
   baked from their own alpha — so the pair reads as one object and both carry
   the same dark edge against a bright map. Verified against the map's measured
   darkest, midpoint and brightest tones.
   To re-tint, rebuild the files; there is no CSS lever.

   (`.wp__photo` and `.wp__icon` used to live here, for the real-photograph
   bubbles and the SVG field sketches. Both registers are gone — all twelve
   waypoints use one generated icon set — so the rules were dead, and
   `.wp__icon`'s seagrass stroke actively pointed at the wrong place when
   hunting for where the icon colour comes from.) */
/* font-size here is only the pre-measurement default — main.js overwrites it
   inline on every layout, deriving it from the SVG's live cover-crop scale so
   the label lands at ~13px on screen at any viewport. Tightened from .12em to
   .08em: at this plate size the wider tracking was most of what made
   "Crown-of-thorns" 275px wide on desktop. */
.wp__label {
  fill: var(--shell);
  font-size: 11px;
  letter-spacing: .08em;
  /* The halo that replaced the plate. stroke-width is set inline by main.js
     from the font size; paint-order keeps it behind the fill. */
  stroke: rgba(6, 21, 18, .92);
  stroke-linejoin: round;
  stroke-linecap: round;
  paint-order: stroke fill;
  /* Centres the text on the same y as the icon square, so the two cannot fall
     out of alignment the way a hand-tuned baseline offset could. */
  dominant-baseline: central;
}

/* Phones crop the chart to its centre band and scale it ~0.7×, so chart type
   gets a compensating bump. .wp__label is NOT in here any more — a two-step
   media query only corrected two points on a continuous curve, and between
   700 and 900px it left the label rendering at about 8px. main.js sizes it
   from the measured scale instead, and re-measures on resize. */
@media (max-width: 700px) {
  .chart__note { font-size: 15px; }
  .chart__compass text { font-size: 17px; }
}

/* ------------------------------------------------------------------ boat -- */
/* Scaled with the artwork when the boat went 72×46 → 42×27: a shadow and a
   wake tuned for the larger vessel read as smudge and rope at this size. */
.chart__boat {
  opacity: 0;   /* main.js drives both transform and opacity */
  filter: drop-shadow(0 2px 4px rgba(6, 21, 18, .7));
}
.boat__wake {
  fill: none;
  stroke: rgba(244, 237, 225, .34);
  stroke-width: 1.1;
  stroke-dasharray: 3 5;
  stroke-linecap: round;
}

/* ---------------------------------------------- epilogue: the data leaves -- */
.chart__datalines path {
  fill: none;
  stroke: var(--seagrass-bright);
  stroke-width: 1.6;
  stroke-dasharray: 3 8;
  stroke-linecap: round;
  opacity: 0;
  transition: opacity .9s var(--ease);
}
.chart__datalines.is-on path { opacity: .55; }
.chart__datalines.is-on path:nth-child(2) { transition-delay: .14s; }
.chart__datalines.is-on path:nth-child(3) { transition-delay: .28s; }
.chart__datalines.is-on path:nth-child(4) { transition-delay: .42s; }

/* ------------------------------------------------------------- the scrim -- */
/* Guarantees stage-copy contrast over the chart's linework: left column on
   desktop, bottom band on mobile. This is a plain gradient, NOT a blended
   layer (see Phase 3 notes — any blended layer must be position:fixed). */
/* Strengthened when the map was brightened for the sunny-day treatment: a
   lighter background under white copy costs contrast, and this scrim is what
   pays for it. Measured after every change to the map filter — see the
   session log. */
.weeks__grade {
  pointer-events: none;
  background: linear-gradient(90deg,
    rgba(6, 21, 18, .88) 0%,
    rgba(6, 21, 18, .74) 30%,
    rgba(6, 21, 18, .34) 50%,
    rgba(6, 21, 18, 0) 68%);
}
@media (max-width: 900px) {
  .weeks__grade {
    background: linear-gradient(0deg,
      rgba(6, 21, 18, .9) 0%,
      rgba(6, 21, 18, .78) 30%,
      rgba(6, 21, 18, .4) 52%,
      rgba(6, 21, 18, 0) 72%);
  }
}

/* ----------------------------------------------------------------- stages -- */
.weeks__stages {
  position: relative;
  width: min(100% - var(--pad) * 2, 720px);
  height: 100%;
  margin-inline: auto;
}

/* Desktop: copy owns the left column, the chart's route lives centre-right —
   the two are composed not to collide (see the geometry contract in
   chart-map.ejs). The old build reserved gutters for the HUD and the rail;
   both are gone. */
@media (min-width: 901px) {
  .weeks__pin { justify-items: start; }
  .weeks__stages {
    margin-left: var(--pad);
    margin-right: 0;
    width: min(46vw, 600px);
  }
}

.stage {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
  text-align: left;
  opacity: 0;
  transform: translateY(var(--sy, 0));
  transition: transform .18s linear;
  color: var(--txt-light);
  text-shadow: 0 1px 24px rgba(6, 21, 18, .34);
}
.stage--hero { align-items: flex-start; }

/* Type scale: TWO steps below the first build (−13%, then a further −8%). The
   stages used to be the whole frame ("poster" duty); now they share it with
   the chart, so the headings yield and let the map read.
   Only the HEADINGS were stepped down. .stage__lead and .stage__body are left
   where they are: at clamp floors of .98–1.02rem they are already at the
   readable limit for light text over a photograph, and item 9 of the same
   brief asks for everything to stay readable. */
.stage__h1 {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(2.1rem, 5.1vw, 3.95rem);
  line-height: 1;
  letter-spacing: -.026em;
  color: var(--shell);
  text-wrap: balance;
  margin: var(--s-4) 0 0;
}
.stage__h1 em { font-style: italic; color: var(--seagrass-bright); }

.stage__h {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(1.55rem, 3.3vw, 2.35rem);
  line-height: 1.07;
  letter-spacing: -.02em;
  color: var(--shell);
  text-wrap: balance;
  margin: var(--s-3) 0 0;
}
.stage__h--big { font-size: clamp(1.8rem, 4.2vw, 3rem); }

.stage__tag {
  font-family: var(--font-mono);
  font-size: .7rem;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--sand);
}
.stage__course {
  margin-top: var(--s-2);
  font-size: .875rem;
  font-weight: 500;
  color: var(--seagrass-bright);
}
.stage__lead,
.stage__body {
  margin-top: var(--s-5);
  font-size: clamp(.98rem, 1.3vw, 1.06rem);
  line-height: 1.62;
  max-width: 56ch;
  color: var(--txt-light);
}
.stage__lead { font-size: clamp(1.02rem, 1.5vw, 1.18rem); }

.stage__cta { display: flex; gap: var(--s-3); flex-wrap: wrap; margin-top: var(--s-6); }

.stage__link {
  display: inline-flex; align-items: center; gap: 7px;
  margin-top: var(--s-5);
  min-height: 44px;
  font-weight: 500;
  color: var(--seagrass-bright);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-color var(--t-micro) var(--ease);
}
.stage__link:hover { border-bottom-color: currentColor; }
.stage__link .icon { transition: transform var(--t-micro) var(--ease); }
.stage__link:hover .icon { transform: translateX(3px); }

.stage__stats {
  display: flex; flex-wrap: wrap; gap: var(--s-5);
  margin-top: var(--s-5); padding: 0; list-style: none;
}
.stage__stats li { font-size: .875rem; color: var(--txt-light-dim); }
.stage__stats .mono {
  display: block;
  font-size: clamp(1.4rem, 2.4vw, 1.85rem);
  color: var(--shell);
  line-height: 1.1;
}

/* Partner rail on the epilogue. Names, not logos — several partners have no
   usable mark and a half-empty logo wall reads worse than a list.

   SCOPED TO `.stage`, because site.css:2126 also declares `.partner-rail` — the
   science partner chips — and both stylesheets load together on / and
   /expedition, where this file comes second and silently won on `gap` and
   `margin-top`. Today the only `.partner-rail` on those pages is this one, so
   nothing looked wrong; adding partner chips to either page, or swapping the
   two <link> tags, would have restyled a component neither author was reading. */
.stage .partner-rail,
.epilogue-band .partner-rail {
  display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-4);
  margin-top: var(--s-6); padding: 0; list-style: none;
}
.stage .partner-rail li span,
.epilogue-band .partner-rail li span {
  font-family: var(--font-mono);
  font-size: .7rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--txt-light-dim);
  transition: color var(--t-micro) var(--ease);
}
.stage .partner-rail li:hover span,
.epilogue-band .partner-rail li:hover span { color: var(--hover-lume); }

/* ---- the same block as a section on the landing page ----
   Owner, 14 Aug 2026: the timeline's closing beat, exactly as it is, on the
   homepage after the science section. Both renderings come from
   partials/data-epilogue.ejs and both take the `.stage__*` type above, so the
   only thing this adds is the frame the timeline's own stage column supplies.

   600px BECAUSE THAT IS THE STAGE COLUMN — `.weeks__stages` is
   `width: min(46vw, 600px)`, and the heading has to break after "You go home."
   the way it does on /expedition. Left to the full 1128px shell it sets on one
   line and stops being the same block.

   THE HEADING AND THE RAIL ONLY. `.stage__body` already carries `max-width:
   56ch`, which is narrower than 600px and is the measure it has on the
   timeline; capping it here as well would beat that rule on specificity and
   quietly widen the paragraph on this page alone. */
.epilogue-band .stage__h,
.epilogue-band .partner-rail { max-width: 600px; }

/* The link to the reports (owner, 14 Aug 2026). `--s-7` clears the partner
   rail, which closes on its own `--s-6` — the same step the page puts between
   a heading and the block under it. It exists on the LANDING PAGE ONLY: the
   /expedition rendering of this block is a stage inside the pinned timeline
   and must not grow. See the note at the call site in home-resurface.ejs. */
.epilogue-band__cta { margin-top: var(--s-7); }

/* --------------------------------------------------------------- the cue -- */
.weeks__cue {
  position: fixed;
  left: 50%; bottom: var(--s-6);
  transform: translateX(-50%);
  z-index: 30;
  display: flex; flex-direction: column; align-items: center; gap: var(--s-3);
  min-height: 44px;
  font-family: var(--font-mono);
  font-size: .6875rem; letter-spacing: .18em; text-transform: uppercase;
  color: var(--txt-light);
  text-decoration: none;
}
.weeks__cue-line {
  width: 1px; height: 40px;
  background: linear-gradient(180deg, var(--sand), transparent);
  animation: cue-drop 2.1s var(--ease) infinite;
}
@keyframes cue-drop {
  0%, 100% { transform: scaleY(.4); transform-origin: top; opacity: .5; }
  50%      { transform: scaleY(1);  transform-origin: top; opacity: 1; }
}

/* ============================== fallback modes ==============================
   Rule 20: the homepage must work in full, calm and reduced modes, and be fully
   readable with JS off. The no-JS case is the one that cannot be tested by
   toggling a class, so it is expressed as `html:not(.has-js)` — site.js adds
   .has-js, exactly as the inner pages do.

   In the stacked modes the chart becomes a dim fixed backdrop: route dotted,
   waypoint dots visible, boat and bubbles hidden. The pin is positioned so the
   stacked copy paints ABOVE the fixed layer. */

/* --- the stacked fallback: a frame too short to hold a stage, or no JS ---
   NOT reduced motion any more. main.js sends prefers-reduced-motion to the
   pinned calm path instead (14 Aug 2026), the same split Ayo's engine uses;
   this class is entry-gated on !stagesFit() alone. --- */
/* Doubled class so this beats `.weeks`'s own negative top margin: the stacked
   modes must NOT start under the header — there is no pinned frame to hide
   behind it, just copy that would be clipped. */
.weeks.weeks--reduced { height: auto; margin-top: 0; }
.weeks--reduced .weeks__pin {
  position: relative; z-index: 1;
  height: auto; display: block; padding: var(--s-8) 0;
  margin-top: 0;   /* the pin no longer sits on top of .weeks__back */
}
.weeks--reduced .weeks__stages { position: static; width: min(100% - var(--pad) * 2, 720px); margin-inline: auto; }
.weeks--reduced .stage {
  position: static; opacity: 1 !important; transform: none !important;
  padding: var(--s-8) 0; border-top: 1px solid var(--line-light);
}
.weeks--reduced .stage:first-child { border-top: 0; }
.weeks--reduced .weeks__cue,
.weeks--reduced .weeks__grade { display: none; }
/* The whole backdrop becomes one dim fixed layer; the stacked copy paints over
   it because .weeks__pin is relative + z-index 1 above this.

   .26 → .12, AND THAT NUMBER IS MEASURED, NOT PICKED (rule 25). This layer sits
   BETWEEN `.weeks`'s --deep-900 background and the copy, so its opacity sets the
   luminance floor the stage text is read against — and at .26 the brightest map
   pixels (the aerial contains literal 255,255,255 sand) lifted that floor to
   rgb(71,82,80). Measured there: --txt-light-dim on .stage__stats and the
   partner rail came out at 3.29:1 and the hero's --seagrass eyebrow at 3.16:1,
   both failing AA. The binding constraint is the DIM token, never the bright one
   — the same trap the full-bleed section scrims hit. Against a pure-white input:
   .16 → 4.17 fails, .14 → 4.36 fails, .12 → 4.54 passes, with the eyebrow at
   5.24. Do not raise it back without re-running those two numbers.
   This was already true for reduced-motion readers; it matters more now that
   short frames route here too. */
.weeks--reduced .weeks__back {
  position: fixed; inset: 0; height: 100vh; z-index: 0;
  opacity: .12;
}
.weeks--reduced .weeks__chart { transform: none; }
.weeks--reduced .wp__bubble,
.weeks--reduced .wp__pulse,
.weeks--reduced .chart__boat,
.weeks--reduced .chart__trace { display: none; }

/* --- screenshot harness: same stacked shape --- */
.weeks.weeks--static { height: auto; margin-top: 0; }
.weeks--static .weeks__pin { position: relative; z-index: 1; height: auto; display: block; padding: var(--s-8) 0; margin-top: 0; }
.weeks--static .weeks__stages { position: static; width: min(100% - var(--pad) * 2, 720px); margin-inline: auto; }
.weeks--static .stage { position: static; opacity: 1 !important; transform: none !important; padding: var(--s-8) 0; }
.weeks--static .weeks__back { position: fixed; inset: 0; height: 100vh; z-index: 0; opacity: .12; }
.weeks--static .weeks__chart { transform: none; }
.weeks--static .wp__bubble,
.weeks--static .wp__pulse,
.weeks--static .chart__boat,
.weeks--static .chart__trace { display: none; }

/* --- no JS: identical to reduced, driven by CSS alone --- */
html:not(.has-js) .weeks { height: auto; background: var(--deep-800); }
html:not(.has-js) .weeks { margin-top: 0; }
html:not(.has-js) .weeks__pin {
  position: relative; z-index: 1;
  height: auto; display: block; padding: var(--s-8) 0;
  margin-top: 0;
}
html:not(.has-js) .weeks__stages { position: static; width: min(100% - var(--pad) * 2, 720px); margin-inline: auto; }
html:not(.has-js) .stage {
  position: static; opacity: 1; transform: none;
  padding: var(--s-8) 0; border-top: 1px solid var(--line-light);
}
html:not(.has-js) .stage:first-child { border-top: 0; }
html:not(.has-js) .weeks__cue,
html:not(.has-js) .weeks__grade,
html:not(.has-js) .weeks__sky { display: none; }
html:not(.has-js) .weeks__back {
  position: fixed; inset: 0; height: 100vh; z-index: 0;
  opacity: .12;   /* same measured floor as .weeks--reduced above */
}
html:not(.has-js) .weeks__chart { transform: none; }
/* No engine, so no tint interpolation — pin it to the static default. */
html:not(.has-js) .map__tint { background-color: rgba(6, 21, 18, .55); }
html:not(.has-js) .wp__bubble,
html:not(.has-js) .wp__pulse,
html:not(.has-js) .chart__boat,
html:not(.has-js) .chart__trace { display: none; }

@media (prefers-reduced-motion: reduce) {
  .weeks__cue-line { animation: none; }
  .wp__pulse { animation: none !important; }
}

/* ========================= below the timeline ========================= */

.band-grid {
  display: grid; gap: var(--s-5);
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  margin-top: var(--s-7);
}
/* SCOPED, for the reason the epilogue partner rail above is: site.css:483 also
   declares `.card--field`, both sheets load together, and this one came second.
   The only `.card--field` on the pages that load this file is the below-scroll
   expedition card, which always carries `.card--media` too. */
.card--media.card--field {
  position: relative;
  border-width: 1px;
  border-style: solid;
  border-radius: var(--r);
  display: flex; flex-direction: column; align-items: flex-start;
}
/* `padding`, `background` and `border-color` are deliberately NOT set above.
   `.card--media` further down owns all three — it strips the padding so the
   photograph reaches the card's edges, and it paints the dark card — and
   scoping this rule to two classes made it MORE specific than that
   single-class rule. Declaring any of the three here beats it: the background
   goes cream-on-cream at 1.10:1, and the padding insets the photograph and
   narrows the text column, which is what "the pictures and the text got
   smaller" was. The card's own padding comes from
   `.card--media > :not(.card--media__img) { margin-inline: var(--s-6) }`. */
.card--field-on { border-color: var(--ochre); }
/* `.card--field__flag` (the "Most chosen" badge) went with the 7 Aug copy pass
   that removed the sales devices; the rule outlived its markup. */

/* ---- media-topped card (the Ayo pattern: image, then body) ---- */
.card--media { padding: 0; overflow: hidden; }
.card--media > :not(.card--media__img) { margin-inline: var(--s-6); }
.card--media > :last-child { margin-bottom: var(--s-6); }
.card--media__img {
  position: relative;
  width: 100%;
  aspect-ratio: 8 / 5;
  overflow: hidden;
  margin-bottom: var(--s-5);
  background: var(--shell-3);
}
.card--media__img img {
  width: 100%; height: 100%;
  object-fit: cover;
  display: block;
  transition: transform .6s var(--ease);
}
.card--media:hover .card--media__img img { transform: scale(1.04); }
/* `.card--media__flag` (the "Most chosen" pill) was removed with its markup —
   it was an unevidenced social-proof claim reading as a sales badge. The
   featured band still gets its emphasis from `.card--field-on`. */
.card--media .card--field__title { margin-top: 0; }

/* ---- the media card is DARK, and that is what makes the photo belong to it ----
   These three photographs are underwater reef and a sunset jetty: their whole
   tonal range is dark blue-green. Sitting them on a --shell-2 card drew a hard
   horizontal line exactly where the photo stopped and the cream began, so each
   card read as two unrelated objects stacked. Painting the body --deep-800 puts
   the card in the same tonal family as its own picture, and the gradient below
   dissolves the join instead of butting the two against each other.

   Scoped to .card--media, which is used ONLY by these three homepage cards
   (grep it). .card--field on its own stays light — six other pages use it on
   light sections and must not move.

   Every on-light token inside therefore needs its on-dark counterpart. That is
   the same failure the session log records four times over: a token specified
   for light surfaces rendering on a dark one. The full set is below; adding a
   new element to this card means adding it here too. */
.card--media {
  background: var(--deep-800);
  border-color: var(--line-light);
}
.card--media__img { background: var(--deep-700); }   /* was --shell-3: a cream flash while loading */
/* The join. Transparent over the top half so the photograph is untouched, then
   down to exactly --deep-800 (#0B211C) at the last pixel so there is no edge to
   see. Change the card background and this stop has to change with it. */
.card--media__img::after {
  content: "";
  position: absolute; inset: 0;
  pointer-events: none;
  background: linear-gradient(180deg,
    rgba(11, 33, 28, 0) 46%,
    rgba(11, 33, 28, .55) 76%,
    rgba(11, 33, 28, 1) 100%);
}
.card--media .card--field__title { color: var(--shell); }
/* The price and per-week lines are gone from these cards — the homepage no
   longer quotes a rate. `__intro` replaced them: the band's own opening
   sentence. Same on-dark rule as everything else in this card. */
.card--media .card--field__intro { color: var(--txt-light); }  /* dim cream is 4.3–4.7:1
   on these cards — under AA. Same fix as .prog__s, 15 Aug: hierarchy is held
   by the title's size, not by fading the body text. Measured 16 Aug. */
.card--media .tick-list li { color: var(--txt-light); }
/* .tick-list .icon already defaults to --seagrass (on-dark) and is only
   overridden inside .section--paper / .section--sand — neither of which is this
   section — so the ticks were seagrass on cream until now and are correct here
   without an override. Left as a note rather than a redundant declaration. */

/* The card's Apply button is `.btn--coral`, the site's primary — the same
   orange as the header's Apply and the closing banner's (owner's call,
   14 Aug 2026). It carries its own background and label colour, measured
   6.22:1 at rest and 7.86:1 on hover, so unlike `.btn--ink` it needs no
   per-surface fail-safe here: `.btn--ink` is `--deep-900` on `--shell`, an
   ON-LIGHT pairing that painted near-black on this near-black card and had to
   be flipped to cream. That override is gone with the class it corrected. */

@media (prefers-reduced-motion: reduce) {
  .card--media__img img, .card--media:hover .card--media__img img { transition: none; transform: none; }
}

/* ---- section with a full-bleed image behind it ---- */
/* The scrim is not optional: these sections carry --shell text, and the
   images run bright. Both are declared here so the pair cannot drift. */
.card--field__title { font-family: var(--font-display); font-size: 1.3rem; font-weight: 500; color: var(--deep-900); }
/* Replaces `__price` / `__rate`: the band's own opening sentence, unedited.
   NOT clamped, and that was measured rather than assumed. A five-line clamp
   looked safe and was not — the 2–3 week sentence is the longest at 167
   characters and needs SIX lines wherever the four-up grid puts the card at
   its narrowest (264–291px, which is 1440 and 1600, i.e. the most common
   desktop widths, plus 1024 and 320). So the clamp was cutting the owners'
   copy off mid-sentence in front of readers, not guarding against a future
   CMS edit.

   Nothing is needed in its place: cards in a grid row already stretch to the
   tallest, so the longest sentence just sets the row height and all four
   cards stay level. Length is bounded anyway — the template takes only the
   first sentence. */
.card--field__intro {
  margin-top: var(--s-3);
  font-size: .9375rem;
  line-height: 1.55;
  color: var(--txt-ink-dim);
}
.card--field .tick-list { margin-top: var(--s-4); }
/* Every card's Apply button sits on the SAME baseline: bottom-aligned via the
   auto margin. (The first build's `> .btn { margin-top: var(--s-5) }` override
   beat the auto margin, so buttons floated at a different height per card —
   and its `padding-top: 0` squashed the pill itself off-centre.) */
.card--field .btn,
.card--field > .btn { margin-top: auto; }
/* Floor between the last content line and the bottom-aligned button, whatever
   the card's final content element happens to be. */
.card--field > :nth-last-child(2) { margin-bottom: var(--s-5); }

/* .prog-grid / .prog MOVED to site.css (14 Aug 2026): the science programme
   pages now render the same card design in their "Other programmes" section,
   and they do not load this stylesheet. One copy, in the sheet every page
   loads — the homepage loads site.css too, so nothing changed there. */

/* ---- the two grids become sliders on a phone (owner, 14 Aug 2026) ----
   "Instead of making all the 8 programmes below each other we need a sliding
   section with the arrows both side and slidable too, same as the village
   pictures" — and the same for the four expedition lengths.

   A MEDIA QUERY OVER THE EXISTING GRID, NOT A SECOND SET OF MARKUP. Both grids
   turn into the village's scroll-snap track below 700px and are ordinary grids
   above it, so there is exactly one copy of each card in the document, the
   reading order never changes, and a phone with no JavaScript still gets a
   swipeable list rather than a broken one. main.js adds the arrows and the
   dots on top and removes them again above the breakpoint.

   700px IS THE VILLAGE SLIDESHOW'S OWN BREAKPOINT (site.css), which is the
   reason to reuse it rather than pick a third number: below it every one of
   these grids is a single column anyway, so the slider replaces a stack and
   never a row.

   THE WHOLE BLOCK MOVED TO site.css ON 14 AUG 2026, unchanged, because
   /science's "Eight programmes" now uses it and that page does not load this
   stylesheet — the same move `.prog` itself made earlier the same day, for the
   same reason. The note that used to sit here said this had to live in
   styles.css because that sheet is linked LATER; that was belt and braces
   rather than a requirement. `.mslide > .prog-grid` is TWO classes against
   `.prog-grid`'s one, so it wins on specificity wherever it sits. */

/* `.split` / `.split--solo` / `.split__img` / `.split__cap` styled the village
   split that the slideshow replaced on 11 Aug. No markup has used them since.
   (`.split-cols` on /transparency is a different, live class.) */

/* `.workstreams` lived here too, colliding with site.css:2437. It is gone:
   the only markup using it is views/pages/village.ejs, which does not load
   this stylesheet, so this copy styled nothing. */

/* Media variant of the shared .empty component (site.css owns the base). The
   base is a centred column with a line sketch; this sets a photograph beside
   the copy and left-aligns it — centred paragraphs next to a hard-edged image
   read as an accident, and the whole point of the variant is that an honest
   "nothing here yet" should still look considered. */
.empty--media {
  display: grid;
  gap: var(--s-6);
  padding: var(--s-5);
  text-align: left;
  align-items: stretch;
}
@media (min-width: 861px) {
  .empty--media { grid-template-columns: 42% 1fr; align-items: center; }
}
/* FRAME PRIMITIVE, LOCAL COPY. site.css owns the canonical rule — see "THE
   FRAME PRIMITIVE" there for why these children are absolutely positioned
   rather than `height: 100%` (Safari does not treat an aspect-ratio height as
   definite, so a percentage child collapses on iOS). This file is loaded on
   its own pages, so the rule is repeated here rather than reaching across. */
.empty__media {
  position: relative;
  border-radius: calc(var(--r) - 4px);
  overflow: hidden;
  aspect-ratio: 8 / 5;
  background: var(--shell-3);
}
.empty__media > picture, .empty__media > img { position: absolute; inset: 0; }
.empty__media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.empty__body { display: flex; flex-direction: column; align-items: flex-start; gap: var(--s-4); }
.empty--media p { max-width: 52ch; }

/* `.quote-grid` was the reviews grid before the rail replaced it; `.quote`
   itself is still live inside `.quote-rail__item`. */
.quote { margin: 0; padding: var(--s-6); border: 1px solid var(--line-ink); border-radius: var(--r); background: var(--shell-2); }
.quote .icon { color: var(--sand); }
.quote blockquote { margin: var(--s-3) 0 0; }
.quote blockquote p { font-size: 1rem; line-height: 1.6; color: var(--txt-ink); }
.quote figcaption { margin-top: var(--s-4); font-size: .8125rem; color: var(--txt-ink-dim); }

/* --------------------------------------------------------- closing banner -- */
.cta-full {
  position: relative;
  min-height: min(78vh, 640px);
  display: grid;
  align-items: center;
  isolation: isolate;
  background: var(--deep-900);
  overflow: hidden;
}
/* The `picture()` helper wraps its image tag in a picture element, which is
   INLINE and has no height of its own. `.cta-full__img { height: 100% }` then
   had nothing to resolve against and fell back to the image's natural height
   — a 1440×1800 portrait rendering 1800px tall inside a 640px banner, clipped
   by overflow. object-fit and object-position were dead the whole time, which
   is why the sunset never appeared however the crop was tuned. Give the
   wrapper the box and both start working. */
.cta-full__media { position: absolute; inset: 0; z-index: -2; }
.cta-full__media picture { display: block; width: 100%; height: 100%; }
/* 65% — measured, not guessed. The image here is CMS-driven and on a seeded
   database it is Barefoot's own `arborek-jetty-sunset-dampier-strait`, a 4:5
   PORTRAIT frame whose sun and jetty sit at 60–65% of its height. A wide, short
   banner crops a portrait source hard, so this value decides whether the
   viewer sees the sunset or a slab of empty sky. Re-measure if the owners
   swap the home-cta image for a landscape one. */
.cta-full__img { width: 100%; height: 100%; object-fit: cover; object-position: center 65%; }
/* Weighted to the LEFT, where the copy sits, instead of a uniform top-to-
   bottom wash — the old version was heavy enough everywhere that a golden
   sunset read as a dark grey rectangle. Worst case at the copy end is a pure
   white pixel under .86: composited rgb(41,54,52) against --shell = 11.2:1,
   so the text is safe however bright the picture gets. The bottom stop is the
   seam into the footer. */
.cta-full__scrim {
  position: absolute; inset: 0; z-index: -1;
  background:
    linear-gradient(180deg, rgba(6, 21, 18, 0) 55%, rgba(6, 21, 18, .55) 100%),
    linear-gradient(90deg,
      rgba(6, 21, 18, .86) 0%,
      rgba(6, 21, 18, .72) 34%,
      rgba(6, 21, 18, .34) 64%,
      rgba(6, 21, 18, .18) 100%);
}
@media (max-width: 900px) {
  .cta-full__scrim {
    background: linear-gradient(180deg, rgba(6, 21, 18, .34) 0%, rgba(6, 21, 18, .8) 62%, rgba(6, 21, 18, .9) 100%);
  }
}
.cta-full__body { padding-block: var(--s-9); color: var(--txt-light); }
.cta-full__h {
  font-family: var(--font-display); font-weight: 500;
  font-size: clamp(2rem, 4.6vw, 3.5rem); line-height: 1.04; letter-spacing: -.024em;
  color: var(--shell); margin-top: var(--s-3); text-wrap: balance;
}
.cta-full__lead { margin-top: var(--s-5); max-width: 52ch; font-size: 1.05rem; line-height: 1.6; }
.cta-full__actions { display: flex; gap: var(--s-3); flex-wrap: wrap; margin-top: var(--s-6); }

/* ------------------------------------------------------------- responsive -- */
@media (max-width: 900px) {
  /* The site header is sticky at z-index 90; the pinned area starts below it.
     --hdr is the measured header height. */
  .weeks__pin {
    padding-top: calc(var(--hdr) + var(--s-4));
    padding-bottom: var(--s-6);
  }
  /* Copy sits at the bottom of the frame (over the bottom scrim); the chart's
     route lives in the upper band. One rule, not the two contradictory ones
     the first build shipped.

     BOTTOM-ALIGNED VIA AN AUTO MARGIN, NOT `justify-content: flex-end`.
     They look identical while the copy fits, and behave completely differently
     when it does not. `flex-end` distributes NEGATIVE free space the same way
     it distributes positive: a stage taller than the frame gets pushed UP, out
     of the top of the box, behind the sticky header — and because the stage is
     absolutely positioned inside a pinned frame there is no way to scroll it
     back into view. That is what hid the epilogue's "You go home. The data
     doesn't." on every phone shorter than ~700px: measured 126px of it gone at
     375×667 and 179px at 360×640.
     An `auto` margin only ever absorbs POSITIVE free space; when there is none
     it resolves to 0. So the copy still sits on the bottom scrim whenever it
     fits, and simply starts from the top of the frame when it does not,
     spending the overflow at the bottom where nothing is hiding it. */
  .stage { justify-content: flex-start; padding-bottom: var(--s-8); }
  .stage--hero { justify-content: flex-start; }
  .stage > :first-child { margin-top: auto; }

  .weeks__stages { width: min(100% - var(--s-5) * 2, 640px); }

  /* The fixed centre-bottom cue landed on top of the hero's CTA buttons once
     the copy became bottom-anchored. Phones don't need a scroll cue. */
  .weeks__cue { display: none; }

  /* The partner rail is eight long institutional names, and at the desktop
     setting it measured 333px on a 375px screen — more than the epilogue's
     heading and body copy combined, and the entire reason that stage
     overflowed its frame. Nothing about the list is trimmed here: the cost is
     all typographic. `.1em` tracking on uppercase mono is what pushed names
     like "BLUD UPTD PENGELOLAAN KAWASAN KONSERVASI DI PERAIRAN KEPULAUAN RAJA
     AMPAT" onto a second line, and the spans were inheriting the 1.62 body
     line-height against a .7rem font — a 2.3× ratio, so every row carried
     roughly its own height again in leading. Tightening both, and the row gap,
     gets the same eight names into well under half the space.

     `display: block` on the span is what makes the line-height bite. The span is
     inline, so the height of each line box was being set by the LI's strut —
     inherited 1.62 against the 1rem body size, i.e. 25.9px per line for an 11.2px
     face — and a line-height on the inline child cannot pull a line box below its
     parent's strut. Blocking it removes the LI's inline formatting context, so
     the declared leading is the leading. Measured 309px → 179px at 360px wide. */
  .stage .partner-rail { gap: var(--s-1) var(--s-4); margin-top: var(--s-5); }
  .stage .partner-rail li span { display: block; letter-spacing: .04em; line-height: 1.45; }

  /* THE INACTIVE WAYPOINTS RECEDE ON PHONES.
     On desktop the copy is a left column and the route lives centre-right, so
     the two never meet and the dots can carry full weight. A phone has no
     column: the copy is full-width along the bottom of the same frame, and the
     map is square and cover-cropped, so the route runs straight down through
     the text. Measured at 375×812, five of the twelve dots sit ON the copy —
     one on the h1, two on the lead — reading as specks of dirt on the
     paragraph rather than as marks on a map.
     Nothing moves and nothing is removed: all twelve waypoints stay exactly
     where the geography put them (see the contract in chart-map.ejs), and the
     count rule 24 asserts is untouched. What changes is weight. A near-black
     disc with a cream ring is the heaviest possible mark to lay over body copy,
     and it was heavier than the route line it belongs to — which is drawn at
     .16 alpha. Dropping the inactive ones to roughly that weight makes them
     read as map furniture; the one that currently matters keeps full strength,
     which is also what makes it legible AS the active one. */
  .wp__dot { opacity: .30; stroke-width: .85; }
  .wp.is-logged .wp__dot { opacity: .58; }
  .wp.is-here .wp__dot { opacity: 1; stroke-width: 1.1; }
}

/* --------------------------------------------- short frames (landscape) --
   Keyed on HEIGHT, not width, because the constraint is the pinned frame and
   nothing else: a phone in landscape, a small laptop and a split-screen tablet
   all hit it. The stages are absolutely positioned inside a frame that cannot
   scroll, so vertical rhythm authored for a 900px frame has to come in when
   there are only ~380px to spend. Spacing only — no type is shrunk and no copy
   is dropped, since the same brief asks for everything to stay readable. */
@media (max-height: 720px) {
  .weeks__pin {
    padding-top: calc(var(--hdr) + var(--s-2));
    padding-bottom: var(--s-4);
  }
  .stage__h1 { margin-top: var(--s-3); }
  .stage__h { margin-top: var(--s-2); }
  .stage__lead,
  .stage__body { margin-top: var(--s-4); }
  .stage__cta { margin-top: var(--s-4); }
  .stage__link { margin-top: var(--s-3); }
  .stage__stats { gap: var(--s-4); margin-top: var(--s-4); }

  /* ---- the large-text tier ----
     Re-measured 18 Aug 2026, and the 14 Aug note above these rules was too
     narrow: it treated the epilogue as the ONE stage that overflows. On a
     375×660 phone the real ladder is

        113%  everything fits
        119%  epilogue +7
        125%  epilogue +34, week 1 +7
        131%  epilogue +62, week 1 +31, week 5 +23, week 8 +20, week 4 +18

     — FIVE stages, so an epilogue-only fix cannot get past 119% however hard
     it squeezes. Swept and confirmed: every epilogue-only lever, alone or
     combined, tops out there. Hence a tier that tightens EVERY stage.

     131% IS THE TARGET BECAUSE IT IS ANDROID'S LARGEST STANDARD FONT SCALE
     (1.30) — not a round number. Below it a common, non-accessibility setting
     costs the reader the whole pinned timeline. Above it the page still
     stacks, which at that point is the right answer.

     THE COMBINATION IS THE MILDEST ONE THAT GETS THERE, and that was swept
     rather than assumed — softening any single part drops it back:
        rhythm + body 1.45 + rail gap 2/1.2  -> 131%   (this)
        rhythm + body 1.45 + rail gap 4/1.25 -> 125%
        rhythm + body 1.50 + rail gap 2/1.2  -> 125%
        rhythm alone, or body alone          -> 113–119%
     Tracking is NOT a lever: .04em/.02em/.01em/0 all give the same 7 rail
     rows at the same height, because the long partner names wrap on whole
     words and shaving hundredths of an em cannot un-wrap one.

     SCOPED TO `.stage`, WHICH IS LOAD-BEARING. The homepage renders the same
     `.stage__h` / `.stage__body` from partials/data-epilogue.ejs inside
     `.epilogue-band`, so the unscoped rules above already reach it on a short
     window. Keying these to the timeline's own `.stage` article leaves that
     rendering byte-for-byte as it was — this is a fix for a frame that cannot
     scroll, and the landing page's copy of the block simply flows. */
  .stage { padding-bottom: var(--s-3); }
  .stage .stage__h { margin-top: var(--s-1); }
  .stage .stage__lead,
  .stage .stage__body { margin-top: var(--s-3); line-height: 1.45; }
  .stage .stage__cta { margin-top: var(--s-3); }
  .stage .stage__link { margin-top: var(--s-2); }
  .stage .stage__stats { gap: var(--s-3); margin-top: var(--s-3); }
  /* The epilogue is the tallest stage on a phone and the rail is most of it —
     231px of a 567px frame at 119%, because three partners are full legal
     names that wrap to two and three lines each. The rail is the one block
     here whose density nobody reads for meaning, so it takes the tightest
     step. Type is NOT shrunk anywhere in this tier: the reader asked for
     bigger text, and answering that by making it smaller is the one move
     that would be worse than stacking. */
  .stage .partner-rail { gap: 2px var(--s-4); margin-top: var(--s-2); }
  .stage .partner-rail li span { display: block; letter-spacing: .04em; line-height: 1.2; }
}
