/* ==========================================================================
   Motion layer — loaded after site.css.

   Built to the timings in the ui-ux-pro-max motion reference, implemented
   natively so the site stays dependency-free:
     - scroll reveal     16px offset, 450ms, power2.out equivalent, play once
     - stagger           70ms per child, capped at 8 children
     - page transition   240ms out / 400ms in (exit faster than entrance)
     - loader loop       1.25s total, under the 1.5s ceiling
     - parallax          decorative layer only, 8% max travel, never text
     - hover             transform and opacity only, reversed on leave

   Everything here is gated on .js, so with JavaScript off the page renders
   fully visible and unanimated. site.css already neutralises durations
   under prefers-reduced-motion; motion.js additionally settles every
   element to its final state.
   ========================================================================== */

:root {
  --ease-out:   cubic-bezier(.22, 1, .36, 1);   /* ≈ power2.out */
  --ease-inout: cubic-bezier(.65, 0, .35, 1);   /* ≈ power2.inOut */
  --light-red:  #e10600;
}

/* Typed custom properties, so the browser interpolates them as numbers
   instead of swapping strings. */
@property --p {
  syntax: '<number>';
  initial-value: 0;
  inherits: true;
}

/* ===================== reading progress (no JS) ========================= */
/* A native scroll-driven animation: the timeline is the document scroller
   itself, so this costs no script and no scroll listener. */

.readbar { display: none; }

@supports (animation-timeline: scroll()) {
  .readbar {
    display: block;
    position: fixed;
    inset: 0 0 auto;
    height: 2px;
    z-index: 60;
    background: linear-gradient(to right, var(--blue), var(--sky));
    transform: scaleX(0);
    transform-origin: left center;
    pointer-events: none;
    animation: readgrow linear both;
    animation-timeline: scroll(root block);
  }
}

@keyframes readgrow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

/* ======================== the racing line =============================== */
/* Drawn down the career timeline as it comes into view — the content really
   is a sequence, so a line that fills as you read it carries information. */

/* --rail-x is the one place the rail position is set. The hover rule on
   each row (.tl::before in site.css) reads it too, so the two can never
   drift apart again — they used to be set independently, at -.9rem and
   -1.25rem, which left a second cyan line 6px off the racing line. */
.tl-wrap { position: relative; --rail-x: -.9rem; }

.raceline {
  position: absolute;
  left: var(--rail-x, -.9rem);
  top: 0;
  width: 3px;
  height: 100%;
  overflow: visible;
}
.raceline path {
  fill: none;
  stroke: var(--blue);
  stroke-width: 2;
  /* No `vector-effect: non-scaling-stroke` here. With it, Chrome lays the
     dashes out in screen pixels and ignores pathLength="100", so the
     100-unit dash meant to be the whole line became a 100px dash with a
     100px gap: the line drew as a broken dashed rule, and the solid hover
     rule on each row sat over whichever dash or gap happened to be there.
     It was never needed for thickness — the line is vertical, so its width
     comes from the x scale, which is 1:1 (3 units across a 3px box).

     `butt`, not `round`: without non-scaling-stroke a round cap is
     stretched by the y scale (~7x) into a long tail past each end. */
  stroke-linecap: butt;
}

@supports (animation-timeline: view()) {
  .raceline path {
    stroke-dasharray: 100;
    stroke-dashoffset: 100;
    animation: drawline linear both;
    animation-timeline: view(block 88% 12%);
  }
}

@keyframes drawline {
  to { stroke-dashoffset: 0; }
}

@media (max-width: 560px) {
  .raceline { display: none; }
}

/* ======================= cross-document transitions ====================== */

@view-transition { navigation: auto; }

@keyframes vt-out { to { opacity: 0; transform: translateY(-8px); } }
@keyframes vt-in  { from { opacity: 0; transform: translateY(14px); } }

::view-transition-old(root) { animation: vt-out .24s var(--ease-inout) both; }
::view-transition-new(root) { animation: vt-in  .40s var(--ease-out)   both; }

/* The masthead is the same object on every page, so it should sit still
   rather than cross-fade with the body content. */
.mast { view-transition-name: mast; }

/* ============================== the loader ============================== */
/* Starting lights. Hidden unless JS turns it on, so it can never strand
   the page. */

.loader { display: none; }

.js-loading .loader {
  display: grid;
  position: fixed;
  inset: 0;
  z-index: 999;
  place-content: center;
  justify-items: center;
  gap: 2.25rem;
  background: var(--navy-deep);
  transform: translateY(0);
}

/* Scroll lock is a separate flag so it can be released while the panel is
   still mid-exit. */
.js-lock { overflow: hidden; }

.loader-lights {
  display: flex;
  gap: clamp(.5rem, 2vw, .9rem);
}

.loader-lights span {
  width: clamp(2.25rem, 7vw, 3.25rem);
  height: clamp(2.25rem, 7vw, 3.25rem);
  border-radius: 50%;
  background: #0b2138;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07);
}

/* Five lights illuminate 180ms apart, hold, then all cut at once. */
.js-loading .loader-lights span {
  animation: light-on .18s steps(1, end) forwards;
}
.js-loading .loader-lights span:nth-child(1) { animation-delay: .06s; }
.js-loading .loader-lights span:nth-child(2) { animation-delay: .24s; }
.js-loading .loader-lights span:nth-child(3) { animation-delay: .42s; }
.js-loading .loader-lights span:nth-child(4) { animation-delay: .60s; }
.js-loading .loader-lights span:nth-child(5) { animation-delay: .78s; }

@keyframes light-on {
  to {
    background: var(--light-red);
    box-shadow: 0 0 1.5rem rgba(225, 6, 0, .55), inset 0 0 0 1px rgba(255, 160, 160, .5);
  }
}

/* The monogram sits above the lights: identity first, then the start. */
.loader-mark {
  height: clamp(3.25rem, 11vw, 4.75rem);
  width: auto;
}
.js-loading .loader-mark { animation: rise .5s var(--ease-out) both; }

.loader-word {
  font-family: var(--display);
  font-size: .8125rem;
  font-weight: 550;
  letter-spacing: .18em;
  color: rgba(255, 255, 255, .38);
  margin: 0;
}

/* Lights out, then the panel lifts away. */
.js-loading.js-go .loader-lights span {
  animation: none;
  background: #0b2138;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07);
  transition: background .12s linear, box-shadow .12s linear;
}
.js-loading.js-go .loader-word,
.js-loading.js-go .loader-mark { opacity: 0; transition: opacity .2s linear; }
/* Held back so the car clears the panel before it lifts. */
.js-loading.js-go .loader {
  transform: translateY(-100%);
  transition: transform .55s var(--ease-inout) .26s;
}

/* =========================== hero entrance ============================== */
/* One orchestrated sequence, run once when the lights go out. */

/* The home page opens on the paint stage, so its title waits for the paint to
   land rather than arriving on page load. The stage block further down owns
   that timing; these are the shared keyframes. */
.js .stage .hero-no,
.js .stage h1 em,
.js .stage h1 > span,
.js .stage .hero-line {
  opacity: 0;
}

.js .stage.painted .hero-no   { animation: rise .7s var(--ease-out) both; }
.js .stage.painted .hero-line { animation: rise .6s var(--ease-out) .55s both; }

/* Line wipe is the fallback; once motion.js has split the title into
   characters the per-character reveal below takes over. */
.js .stage.painted h1:not(.chars-split) > span { animation: wipe .62s var(--ease-out) .12s both; }
.js .stage.painted h1:not(.chars-split) em     { animation: wipe .62s var(--ease-out) .22s both; }

/* ---- per-character title reveal ---------------------------------------- */

.js .stage h1.chars-split { perspective: 620px; }
.js .stage h1.chars-split > span,
.js .stage h1.chars-split em { opacity: 1; }

.js .stage h1.chars-split .ch {
  display: inline-block;
  opacity: 0;
  transform: translate3d(0, .55em, 0) rotateX(-72deg);
  transform-origin: 50% 100%;
  will-change: transform, opacity;
}
.js .stage.painted h1.chars-split .ch {
  animation: charIn .64s var(--ease-out) both;
  animation-delay: calc(var(--i) * 26ms);
}

@keyframes charIn {
  from { opacity: 0; transform: translate3d(0, .55em, 0) rotateX(-72deg); }
  to   { opacity: 1; transform: none; }
}

@keyframes rise {
  from { opacity: 0; transform: translate3d(0, 14px, 0); }
  to   { opacity: 1; transform: none; }
}

/* The name arrives as a line wiping open — reads as speed without the
   per-character noise of a split-text reveal. */
@keyframes wipe {
  from { opacity: 0; clip-path: inset(0 100% 0 0); transform: translate3d(-10px, 0, 0); }
  to   { opacity: 1; clip-path: inset(0 -2% 0 0); transform: none; }
}

/* Interior pages get the short version. */
.js .phead h1 { opacity: 0; }
.js-ready .phead h1 { animation: wipe .6s var(--ease-out) .05s both; }
.js .phead p { opacity: 0; }
.js-ready .phead p { animation: rise .55s var(--ease-out) .22s both; }

/* ============================ scroll reveal ============================= */

.js .reveal {
  opacity: 0;
  transform: translate3d(0, 16px, 0);
  transition: opacity .45s var(--ease-out), transform .45s var(--ease-out);
}
.js .reveal.in { opacity: 1; transform: none; }

/* Data rows fade without travel — an overshoot or slide on a results table
   reads as sloppy. */
.js .res tbody tr {
  opacity: 0;
  transition: opacity .4s linear;
}
.js .res tbody tr.in { opacity: 1; }

/* ============================== parallax ================================ */

.js .hero-img { will-change: transform; }

/* ============================ hover states ============================== */

/* Deliberately no hover-zoom on the photography: a scale-up on every image
   card is the default move and adds nothing here. The photos are the
   content, so they hold still.

   Press cards get a blue edge that wipes in from the top — it marks the row
   as a link rather than decorating it. */
.cards li { position: relative; }
.cards li::before {
  content: "";
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 3px;
  background: var(--blue);
  transform: scaleY(0);
  transform-origin: top;
  transition: transform .28s var(--ease-out);
}
@media (hover: hover) {
  .cards li:hover::before { transform: scaleY(1); }
}
.cards li:focus-within::before { transform: scaleY(1); }

/* Buttons acknowledge the press rather than float on hover. */
.btn { transition: background .15s, border-color .15s, transform .12s var(--ease-out); }
.btn:active { transform: translateY(1px); }

/* ======================== the paint stage =============================== */
/* The home page opens on the car rendered colourless and near-black. Scroll
   drives --p from 0 to 1, which sweeps a soft diagonal edge across the frame
   and paints the real photograph in behind it.
   Without JS the tall scroll track collapses and the colour frame simply
   shows, so the page is never dependent on the effect. */

.stage {
  position: relative;
  height: 100vh;
  min-height: 34rem;
  background: var(--navy-deep);
}

/* The opening is now a load sequence, not a scroll one — no tall track, so
   the page's information starts one screen down instead of three.
   --p is a registered <number>, so CSS can interpolate it directly and every
   rule in this file follows from this single animation. */
.js .stage { --p: 0; }
.js-ready .stage { animation: heroin 2.1s var(--ease-out) both; }

@keyframes heroin {
  from { --p: 0; }
  to   { --p: 1; }
}

.stage-frame {
  position: relative;
  height: 100vh;
  min-height: 34rem;
  overflow: hidden;
  color: #fff;
}

/* ---- the timing field --------------------------------------------------
   Generated by hero-graphics.js: a column of hairlines with a light
   running along them, plus three long sector rules. `currentColor` drives
   every stroke, so the whole field is retinted by setting `color` here.

   This replaced a set of drifting S-curves. Those were generic ribbon art
   and against a lit portrait on navy they read as scratches on the glass;
   this is the same furniture as the lap table and the results ticker. */

[data-hero-grid] {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  color: var(--cyan);
}
.herogrid-svg { display: block; width: 100%; height: 100%; }

@keyframes gridpulse {
  0%, 68%, 100% { opacity: inherit; }
  12%           { opacity: .42; }
}

/* The field comes up first, before the light finds the driver. */
.js .stage [data-hero-grid] { opacity: calc(var(--p) * 1.35); }

/* The same 135° hatch the section panels carry, so the hero belongs to the
   page rather than being a photograph bolted to the top of it. Held very
   low and masked away from the centre, where the driver is. */
.stage-hatch {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(135deg,
    rgba(76, 182, 234, .055) 0px, rgba(76, 182, 234, .055) 1px,
    transparent 1px, transparent 9px);
  -webkit-mask-image: radial-gradient(ellipse 58% 62% at 50% 52%, transparent 38%, #000 86%);
          mask-image: radial-gradient(ellipse 58% 62% at 50% 52%, transparent 38%, #000 86%);
}
.js .stage .stage-hatch { opacity: calc(var(--p) * 1.2); }

/* ---- the driver -------------------------------------------------------- */

.stage-figure {
  position: absolute;
  left: 50%;
  bottom: 0;
  transform: translateX(-50%);
  height: 94%;
  width: auto;
  max-width: none;
  object-fit: contain;
  object-position: bottom center;

  /* The frame is a rectangle with its own studio backdrop, a shade lighter
     than this page. Dropped in flat you see the box it was shot in. Two
     masks, intersected, dissolve it into the navy on all four sides so he
     emerges from the dark.

     The numbers are not arbitrary:
       sides   — he spans roughly 11% to 91% of the file's width, so the
                 fades stop at 9% and anything deeper eats his arms.
       bottom  — the generated clip carries a "KlingAI 3.0" watermark in
                 the bottom-right, measured at 88.6% to 100% of the height.
                 The mask is fully transparent from 87% down, which clears
                 it with margin to spare rather than leaving a ghost of it
                 showing through a partial fade. It also happens to be the
                 better composition: he fades out behind his own name
                 instead of stopping at a hard edge. */
  --fig-fade-bottom: 87%;
  -webkit-mask-image:
    linear-gradient(to right, transparent 0%, #000 9%, #000 91%, transparent 100%),
    linear-gradient(to bottom, transparent 0%, #000 15%, #000 76%, transparent var(--fig-fade-bottom));
          mask-image:
    linear-gradient(to right, transparent 0%, #000 9%, #000 91%, transparent 100%),
    linear-gradient(to bottom, transparent 0%, #000 15%, #000 76%, transparent var(--fig-fade-bottom));
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
}

/* The poster frame and the clip are the same photograph at the same aspect
   ratio, so nothing shifts when one replaces the other. */
video.stage-figure { background: transparent; }

/* Spotlight: a bloom behind the figure that grows as the light comes up, so
   the surround stays dark and the driver is what brightens. */
.stage-glow {
  position: absolute;
  left: 50%;
  bottom: -6%;
  width: min(78rem, 128vw);
  aspect-ratio: 1 / 1;
  transform: translateX(-50%);
  pointer-events: none;
  background: radial-gradient(circle at 50% 62%,
    rgba(76, 182, 234, .36) 0%,
    rgba(31, 98, 158, .20) 26%,
    rgba(13, 36, 66, .10) 44%,
    transparent 62%);
}

.js .stage .stage-figure {
  /* starts sunk in the dark, then the light and a little scale bring it up */
  filter: brightness(calc(.26 + var(--p) * .74)) contrast(calc(1.06 - var(--p) * .06));
  transform: translateX(-50%) scale(calc(1.05 - var(--p) * .05));
}
.js .stage .stage-glow { opacity: calc(var(--p) * var(--p)); }

@media (max-width: 700px) {
  /* On a phone the figure is the whole composition, so it sits lower and
     larger and the glow tightens around it. */
  .stage-figure { height: 78%; }
  .stage-glow { width: 150vw; bottom: -12%; }
}

/* ---- hero atmosphere ---------------------------------------------------
   A duotone tint and corner vignette give the frame depth without burying
   the paint reveal, so the tint is strongest at the corners and near zero
   through the middle where the car sits. */

.stage-vig,
.stage-mark {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.stage-vig {
  background: radial-gradient(ellipse 78% 70% at 50% 46%,
    transparent 44%,
    rgba(4, 16, 31, .46) 84%,
    rgba(6, 21, 39, .74) 100%);
}

/* The car number carried across the frame — an outline now rather than a
   soft-light fill. The filled version was a grey mass behind the driver's
   shoulders that fought the photograph for the same tonal range; drawn as
   a stroke it sits behind him as a mark on the wall, and it is the same
   treatment the hero's own number plate uses. */
.stage-mark {
  display: grid;
  place-items: center;
  font-family: var(--display);
  font-size: clamp(13rem, 40vw, 34rem);
  font-weight: 800;
  font-stretch: var(--cond);
  line-height: 1;
  letter-spacing: -.04em;
  color: transparent;
  -webkit-text-stroke: 3px rgba(76, 182, 234, .22);
  user-select: none;
}

@media (max-width: 700px) {
  /* The stroke is a fixed width, so at phone sizes it needs to come down
     with the glyph or the number turns into a solid slab. */
  .stage-mark { -webkit-text-stroke-width: 2px; }
}

/* The veil that makes the title legible would bury the car at the start, so
   it comes up with the paint rather than sitting there from the top. */
.js .stage .hero-veil {
  opacity: calc(.22 + var(--p) * .78);
}

/* ---- upper left: origin ------------------------------------------------ */

.stage-place {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: 100%;
  max-width: var(--page);
  padding-inline: var(--gut);
}

.stage-place { top: clamp(4.75rem, 10vh, 7.5rem); }

.stage-nat {
  max-width: none;
  font-family: var(--display);
  font-size: .6875rem;
  font-weight: 500;
  font-stretch: 100%;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 0 0 .6rem;
}
.stage-city {
  font-family: var(--display);
  font-size: clamp(1.75rem, 4.4vw, 3.25rem);
  font-weight: 400;
  font-stretch: 104%;
  line-height: .98;
  letter-spacing: -.028em;
  color: #fff;
  margin: 0;
  max-width: 18ch;
}
.stage-city span { display: block; color: rgba(255, 255, 255, .74); }

/* ---- lower right: points by season ------------------------------------ */

/* `max-width: none` is load-bearing. This is a <p>, so it inherits the
   68ch reading measure from site.css; inside a right-aligned block that
   makes a box 68 characters wide sitting at the left, with its text
   right-aligned to the far end of it — which left the label stranded in
   the middle of the frame, nowhere near its own numbers. */
/* Past seasons are subordinate to this one, but they are real figures, not
   decoration — so the hierarchy is carried by size and colour, not by
   dropping them below a readable contrast. */
/* This season's figure is the one that matters. */

/* Both blocks arrive early, so the frame has something to read before the
   paint has finished and the name lands. */
.js .stage .stage-place,

@media (max-width: 860px) {
  /* Would collide with the name, and the same figures are in the results
     table further down. */
}
@media (max-width: 700px) {
  .stage-mark { font-size: 58vw; }
  .stage-place { top: 4.5rem; }
  /* The eyebrow and the first line of the headline share their rows with
     the social marks, which site.css sets at three 2.25rem marks with .6rem
     gaps (7.95rem). On a phone they stop short of that and wrap instead of
     running underneath. min() keeps the headline's own 18ch measure. */
  .stage-nat  { max-width: calc(100% - 7.95rem - .75rem); }
  .stage-city { max-width: min(18ch, calc(100% - 7.95rem - .75rem)); }
  /* The grey line always lands below the marks, so it needn't stop short:
     size it to its own text, capped at the page width. */
  .stage-city span { width: max-content; max-width: calc(100vw - 2 * var(--gut)); }
  /* .stage-frame is 100vh, but it sits under the 59-62px sticky masthead,
     and on a phone 100vh is the tallest the viewport gets (toolbars
     retracted). Together they pushed the hero line and part of the name
     below the first screen. 100svh is the viewport with the toolbars
     showing; 100vh stays first as the fallback for older browsers. */
  .stage-frame { height: calc(100vh - 3.875rem); height: calc(100svh - 3.875rem); }
}

/* Copy fades up over the last stretch of the sweep. */
.js .stage .hero-copy {
  opacity: calc((var(--p) - .52) * 2.6);
}

/* A quiet hint that the page wants scrolling, gone as soon as it starts. */
.stage-cue {
  position: absolute;
  left: 50%;
  bottom: 1.75rem;
  transform: translateX(-50%);
  font-family: var(--display);
  font-size: .6875rem;
  font-weight: 550;
  letter-spacing: .16em;
  color: rgba(255, 255, 255, .55);
  display: none;
}
/* Arrives once the light is up, rather than fading as it rises — there is a
   page below and nothing else is hinting at it. */
.js .stage-cue { display: block; opacity: calc((var(--p) - .8) * 4); }

/* ========================== the straight ================================ */
/* A dark band the car crosses once. It shares the ink of the gallery
   section below it, so it reads as the run-in to that block rather than a
   stripe of its own. */

.straight {
  position: relative;
  overflow: hidden;
  background: var(--ink);
  height: clamp(5.5rem, 13vw, 8.5rem);
}

/* Speed lines. Static and nearly invisible until the car comes past. */
.straight-line {
  position: absolute;
  left: 0;
  height: 2px;
  width: 100%;
  background: linear-gradient(to right, transparent, rgba(86, 174, 228, .5), transparent);
  opacity: 0;
  transform: scaleX(.2);
  transform-origin: left;
}
.straight-line-1 { top: 38%; }
.straight-line-2 { top: 55%; height: 1px; }
.straight-line-3 { top: 70%; }

.racecar {
  position: absolute;
  bottom: 12%;
  left: 0;
  width: clamp(7rem, 17vw, 11.5rem);
  height: auto;
  transform: translateX(-130%);
}

/* Static fallback: with JS off, the car simply sits on the left. */
.straight .racecar { transform: none; }
.js .straight .racecar { transform: translateX(-130%); }

/* The pass, triggered once when the band scrolls into view. */
.js .straight.go .racecar {
  animation: drive 2.6s cubic-bezier(.45, 0, .5, 1) both;
}
.js .straight.go .straight-line {
  animation: streak 2s ease-out both;
}
.js .straight.go .straight-line-2 { animation-delay: .14s; }
.js .straight.go .straight-line-3 { animation-delay: .28s; }

@keyframes drive {
  from { transform: translateX(-130%); }
  to   { transform: translateX(calc(100vw + 30%)); }
}

@keyframes streak {
  0%   { opacity: 0; transform: scaleX(.15); }
  35%  { opacity: 1; transform: scaleX(1); }
  100% { opacity: 0; transform: scaleX(1); }
}

/* ====================== the car on the loader =========================== */
/* Lights out, the car goes past, then the panel lifts. */

.loader-car {
  position: absolute;
  left: 0;
  bottom: 22%;
  width: clamp(6rem, 16vw, 9.5rem);
  height: auto;
  transform: translateX(-140%);
  opacity: 0;
}
.js-loading.js-go .loader-car {
  animation: launch .62s cubic-bezier(.42, 0, .4, 1) both;
  opacity: 1;
}

@keyframes launch {
  from { transform: translateX(-140%); }
  to   { transform: translateX(calc(100vw + 40%)); }
}

/* ===================== gallery rail scroll progress ===================== */

.rail { cursor: grab; }
.rail.dragging { cursor: grabbing; scroll-snap-type: none; }
.rail.dragging img { pointer-events: none; }

/* ====================== gallery rail — running strip ==================== */
/* The frames cross the page continuously, the way the results ticker does.
   This only takes effect once motion.js has cloned the frame set and
   measured a duration onto the section; with JS off, or under reduced
   motion, the rail above stays an ordinary scrollable row, which is the
   honest fallback rather than a strip that never moves. */

.rail.is-marquee {
  display: block;
  overflow: hidden;
  cursor: default;
  scroll-snap-type: none;
  /* The gutter moves onto the sets: a padded overflow container would clip
     the frames before the fade could reach them. */
  padding-inline: 0;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
          mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
}

/* --- the shared loop primitives -----------------------------------------
   `.rail-run`, `.rail-set` and `.rail-hold` are used by two things now:
   the gallery strip here and the partner banner at the foot of the page.
   They keep the `rail-` prefix of their first use. Anything that is
   specific to the gallery is scoped under `.rail` below; anything
   specific to the banner is scoped under `.banner`.

   Two transforms on two elements, deliberately. The loop lives on
   .rail-run and the scroll nudge on .rail-drift, so the scroll handler
   never has to know where in its loop the strip currently is — it just
   writes its own offset and the two compose. */
.rail-run {
  display: flex;
  width: max-content;
  animation: railmove var(--raildur, 60s) linear infinite;
  will-change: transform;
}

/* Each half carries its own trailing gap so the two halves are exactly
   equal in width. With the gap on .rail-run instead, one gap would fall
   between the halves and none after the second, and the -50% wrap would
   land half a gap out — a small sideways jolt once per loop. */
.rail-set {
  display: grid;
  grid-auto-flow: column;
  align-items: center;
  gap: 1rem;
  margin-right: 1rem;
}

/* The gallery's frames are wide; the banner sizes to its marks instead. */
.rail .rail-set { grid-auto-columns: min(78vw, 34rem); }

@keyframes railmove {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

/* In marquee mode the bar stops reporting scroll position — there is none —
   and reports the loop instead, on the same clock as the strip. */

/* One hold class, set by JS for hover, keyboard focus, the toggle and
   leaving the viewport, so the strip and its bar can never drift apart.
   The bar's selector has to carry the marquee qualifier too: the rule that
   starts it is a sibling selector of three classes, and the `animation`
   shorthand there resets play-state to running, so a shorter selector here
   loses and the bar keeps sweeping over a stopped strip. */
.rail-hold .rail-run { animation-play-state: paused; }

.rail-set figure {
  position: relative;
  transition: transform .5s var(--ease-out);
}
.rail.is-marquee:hover .rail-set figure { transform: scale(.985); }
.rail.is-marquee .rail-set figure:hover { transform: scale(1); }

/* ======================== the partner banner ============================
   The sponsor marks crossing the foot of the page on the same loop as the
   gallery strip. Built by motion.js; with JavaScript off, or under reduced
   motion, nothing here applies and the plain grid in the markup stands. */

/* The band is a board, not a row of logos on a page — a lit screen in a
   dark bezel, the way a sponsor ribbon reads trackside. Everything that
   makes a mark legible now belongs to the whole surface rather than to
   each logo, so there are no light patches travelling with the artwork
   and no boxes: it is one continuous lit face with the marks crossing it.

   The hard edges are deliberate. An edge fade here would say "this
   content is flowing past a window"; a screen says "this is the face of
   a board", and it keeps content visible right to the frame. */
.banner {
  position: relative;
  margin: clamp(2rem, 4.5vw, 3.1rem) 0 0;
  padding: .5rem;
  background: linear-gradient(180deg, #0e1f33 0%, #061322 100%);
  border: 1px solid var(--hair);
  box-shadow: 0 1.25rem 2.5rem rgba(0, 0, 0, .35);
}

.banner-mask {
  position: relative;
  min-width: 0;
  overflow: hidden;
  /* The face. Cool white, brightest through the middle, so the board
     reads as lit from within rather than as a sheet of paper. */
  background:
    radial-gradient(ellipse 72% 140% at 50% 46%, #ffffff 0%, #eef3f8 58%, #dde5ee 100%);
  box-shadow:
    inset 0 0 0 1px rgba(10, 25, 45, .25),
    inset 0 .55rem 1.1rem rgba(10, 25, 45, .14);
}

/* The pixel grid. Two 3px repeating gradients multiplied over the face
   give the fine lattice of an LED board — enough to read as a screen at
   a glance, far too fine to interfere with a logo. */
.banner-mask::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 2;
  mix-blend-mode: multiply;
  opacity: .5;
  background:
    repeating-linear-gradient(90deg, rgba(10, 25, 45, .13) 0 1px, transparent 1px 3px),
    repeating-linear-gradient(0deg,  rgba(10, 25, 45, .13) 0 1px, transparent 1px 3px);
}

/* The <ul> stays a list; it is only the track container now, so the grid
   that laid the marks out in cells has to come off. */
.logos.is-banner {
  display: block;
  background: none;
  border: 0;
  margin: 0;
  padding: 0;
}
.logos.is-banner > li { display: none; }        /* the originals moved into the sets */

/* One row height for all three, tall enough for the deepest mark; each
   logo is centred in its own cell and lit from behind by the wash defined
   on `.logos li` in site.css. */
.logos.is-banner .rail-set > li {
  display: grid;
  place-items: center;
  min-height: 0;
  height: 7.25rem;
  padding: 0 1.25rem;
}

/* 88px between marks and nothing drawn between them — the reference lets
   the spacing do that job, and with three marks this different a divider
   only draws attention to how unlike each other they are. The trailing
   gap sits on the set so the two halves stay exactly equal and the wrap
   lands clean. */
.banner .rail-set {
  grid-auto-columns: max-content;
  gap: 5.5rem;
  margin-right: 5.5rem;
}

@media (max-width: 700px) {
  .banner .rail-set { gap: 3rem; margin-right: 3rem; }
}

/* No pause control on any of the three strips — see "Stopping the strips"
   in the README for what stops them now and what that costs. */

/* ========================= reduced motion =============================== */
/* site.css already collapses durations; this removes the offsets and
   clip-paths so nothing can be left mid-animation. */

@media (prefers-reduced-motion: reduce) {
  .js .reveal,
  .js .res tbody tr,
  .js .hero-no,
  .js h1 em,
  .js h1 > span,
  .js .hero-line,
  .js .ledger-cell,
  .js .phead h1,
  .js .phead p {
    opacity: 1 !important;
    transform: none !important;
    clip-path: none !important;
    animation: none !important;
  }
  .js .hero-img { will-change: auto; }
  .rail { cursor: default; }

  /* No light-up sequence: the hero is simply lit and legible. */
  .js .stage { --p: 1; animation: none; }
  .js-ready .stage { animation: none; }
  .js .stage .hero-copy { opacity: 1; }
  .js .stage .stage-figure { filter: none; transform: translateX(-50%); }
  .js .stage h1 em,
  .js .stage h1 > span,
  .js .stage h1.chars-split .ch,
  .js .stage .hero-line { opacity: 1 !important; animation: none !important; clip-path: none !important; transform: none !important; }
  .stage-cue { display: none !important; }
  .readbar { display: none !important; }
  .raceline path { stroke-dashoffset: 0 !important; animation: none !important; }
  .js .stage-color { -webkit-mask-image: none !important; mask-image: none !important; }

  /* The car holds still on the straight instead of driving past. */
  .js .straight .racecar,
  .js .straight.go .racecar {
    transform: none !important;
    animation: none !important;
  }
  .straight-line { opacity: 0 !important; animation: none !important; }
  .loader-car { display: none !important; }
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
}

/* The social mark arrives early with the place block, not with the name.
   Scoped to :not(.painted) so a failure to reach `painted` leaves the mark
   visible rather than invisible. */
.js .stage:not(.painted) .hero-social { opacity: 0; }
.js .stage.painted .hero-social { animation: rise .6s var(--ease-out) .2s both; }

@media (prefers-reduced-motion: reduce) {
  .js .stage .hero-social {
    opacity: 1 !important;
    animation: none !important;
    transform: none !important;
  }
}
