/* RUNROO Racing Services, the merged site (2026-09-21).
   Website Gold is the base; the sections Josh and Angel chose from Website
   Blue are ported in. Gold and black only: every colour is a RUNROO token.
   The header, menu, footer and assistant live in chrome.css. */

:root {
  --black: #000000;
  --panel: #0A0A0A;
  --navy: #0A0A0A;
  --deep: #0A0A0A;
  --raised: #1F1F1F;
  --gold: #D4A72C;
  --gold-hover: #DCB752;
  --gold-pressed: #B69026;
  --white: #FFFFFF;
  --muted: #9E9E9E;
  --meta: #808080;
  --rule: #1F1F1F;
  --page-pad: clamp(1.25rem, 4vw, 4.5rem);
  --content: 86rem;
  --nav-h: 5rem;
  --hero-progress: 0;
  --opening-alpha: 1;
  --middle-alpha: 0;
  --start-alpha: 0;
  --lines-alpha: 0;
  --hero-exit: 0;
  --race: 0;
}

* { box-sizing: border-box; }
/* overflow-x:clip on both html and body: body alone doesn't stop it -- html
   is the actual scrolling viewport, so a wide/off-canvas descendant (e.g.
   .hero-canvas's deliberate off-viewport bleed) was still reachable by an
   explicit horizontal scroll on phone widths without this. */
html { scroll-behavior: smooth; background: var(--black); color-scheme: dark; overflow-x: clip; }
body {
  margin: 0;
  overflow-x: clip; /* clip, not hidden: hidden would make body the scroller and break the sticky stages */
  background: var(--black);
  color: var(--white);
  font-family: "Satoshi", Arial, sans-serif;
  font-size: 1rem;
  line-height: 1.6;
  text-rendering: optimizeLegibility;
}
body.menu-open { overflow: hidden; }
img, video { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button, input, textarea { font: inherit; }
button, a, input, textarea { -webkit-tap-highlight-color: transparent; }
:focus-visible { outline: 2px solid var(--white); outline-offset: 3px; }



h1, h2, h3 {
  margin: 0;
  font-family: "Clash Display", Arial, sans-serif;
  font-weight: 700;
  text-transform: uppercase;
}
h1, h2 { word-spacing: 0.2em; letter-spacing: -0.03em; line-height: 0.94; }
h3 { word-spacing: 0.12em; letter-spacing: -0.015em; line-height: 1.08; }
h1 { font-size: clamp(3.25rem, 8vw, 7.5rem); }
h2 { font-size: clamp(2.75rem, 6vw, 6rem); }
h3 { font-size: clamp(1.4rem, 2.2vw, 2rem); }
p { margin: 0; }
.eyebrow {
  color: var(--gold);
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.18em;
  line-height: 1.2;
  text-transform: uppercase;
}
.section-shell { width: min(var(--content), 100%); margin: 0 auto; padding: 0 var(--page-pad); }


.button {
  position: relative;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 3rem;
  padding: 0.75rem 1.3rem;
  border: 1px solid transparent;
  border-radius: 999px;
  cursor: pointer;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.09em;
  line-height: 1;
  text-transform: uppercase;
  transition: background 160ms ease, color 160ms ease, border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
}
.button:hover { transform: translateY(-2px); }
/* The shimmer sweep and gold-glow hover already used on the inner pages
   (inner.css .btn) and Website Blue, ported so homepage buttons match. */
.button::after {
  content: ""; position: absolute; top: 0; bottom: 0; left: -30%; width: 30%; pointer-events: none;
  background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.5), transparent);
  transform: skewX(-18deg) translateX(-160%); transition: transform 750ms ease;
}
.button:hover::after { transform: skewX(-18deg) translateX(560%); }
.button-primary { background: var(--gold); color: var(--black); border-color: var(--gold); }
.button-primary:hover { background: transparent; color: var(--gold); box-shadow: 0 0 0 1px rgba(212, 167, 44, 0.35), 0 0 28px rgba(212, 167, 44, 0.4); }
.button-primary:active { background: var(--gold-pressed); color: var(--black); transform: none; }
.button-secondary { border-color: rgba(255, 255, 255, 0.38); background: transparent; color: var(--white); }
.button-secondary:hover { border-color: var(--gold); background: transparent; color: var(--gold); box-shadow: 0 0 22px rgba(212, 167, 44, 0.22); }

/* Round 5: #races overlaps this section's last 100vh (margin-top:-100vh on
   .section-races) and paints over it (.section-races has the higher
   z-index), so the hero-stage is covered in place instead of scrolling away
   in normal flow underneath a separately-scrolled #races - that gap was the
   two-stacked-videos seam. position:relative only to give z-index somewhere
   to stack from; layout is unaffected. */
.hero { position: relative; z-index: 1; height: 840vh; } /* Angel 9/29 items 2-3: ~30% less scroll (was 1199vh). Beats are fractions of offsetHeight, so they scale with it. */
.hero-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  min-height: 38rem;
  overflow: hidden;
  isolation: isolate;
  background: var(--black);
}
.hero-fallback, .hero-canvas, .hero-intro, .hero-intro-video, .hero-scrim { position: absolute; inset: 0; width: 100%; height: 100%; }
.hero-fallback { background: var(--black) url("assets/poster-intro-plate.jpg") center 44% / cover no-repeat; }
.hero-canvas { z-index: 1; }
.hero-intro { z-index: 2; opacity: 1; visibility: visible; transition: opacity 400ms ease, visibility 400ms ease; }
.hero-intro.is-done { opacity: 0; visibility: hidden; }
.hero-intro-video { object-fit: cover; object-position: center 44%; }
.intro-lockup { position: absolute; left: 50%; top: 18%; width: clamp(11rem, 18vw, 17rem); transform: translateX(-50%); opacity: 0.86; }
/* H1: no full-frame tint after the opening. Two layers only: a permanent
   soft gradient confined to the bottom ~38% of the frame (transparent above
   that), constant on every beat, for the text sitting there; and a
   full-frame layer that only ever carries the opening's --tint, which is
   fully 0 (not just faint) within the first screen (see OVERLAY_FADE_END). */
.hero-scrim {
  z-index: 3;
  background:
    linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, transparent 38%),
    rgba(0, 0, 0, calc(var(--tint, 0) * 0.4));
}
/* Round 11, item 7a (Dylan direct): the old bottom:clamp(7.5rem,14vh,10.5rem)
   floors at 7.5rem regardless of how short the viewport gets, so on tablet
   heights the block stayed pinned near the bottom edge while its own content
   (fixed-size text, doesn't shrink) got crunched against it. top-anchored
   instead: min() of a vh-fraction and a "100vh minus the block's own rough
   height" keeps it near its old spot on tall/wide screens but rides upward
   as the viewport shortens, instead of getting shoved off/crunched. */
.hero-content {
  position: absolute;
  z-index: 5;
  left: var(--page-pad);
  top: min(58vh, calc(100vh - 21rem));
  width: min(34rem, calc(100% - (2 * var(--page-pad))));
  transition: opacity 220ms linear, transform 220ms linear;
}
.hero-content h1 { font-size: clamp(2.6rem, 5vw, 4.75rem); }
.hero-content h2 { font-size: clamp(2.1rem, 3.6vw, 3.5rem); }
.hero-middle h2 { font-size: clamp(1.7rem, 2.8vw, 2.75rem); font-style: italic; font-weight: 600; line-height: 1.08; }
.hero-content .eyebrow { margin-bottom: 1.25rem; }
.hero-content h1 span, .hero-content h2 span { color: var(--gold); }
/* 2026-10-01: marks "Every race" so the phone hero can color it gold while "starts here." goes white. No-op everywhere else (inherits the h1 color), so desktop is unchanged. */
.hero-content h1 .h1-first { color: inherit; }
/* The overlay only covers the opening beat now (see --tint below), so every
   hero text block carries its own soft shadow for legibility over the clear film. */
.hero-content h1, .hero-content h2, .hero-content .eyebrow, .hero-lead { text-shadow: 0 2px 24px rgba(0, 0, 0, 0.6); }
/* Round 14 (Dylan item 1): the small descriptor read grey on the film. Brighter
   (near-white instead of --muted #9E9E9E), a half step heavier, and a tighter
   dark halo behind the letters so the font reads clearly. */
.hero-lead { max-width: 30rem; margin-top: 1.25rem; color: rgba(255, 255, 255, 0.94); font-weight: 500; font-size: clamp(0.98rem, 1.2vw, 1.1rem); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85), 0 2px 18px rgba(0, 0, 0, 0.7); }
/* Dylan round 6, phone item 1: shortened phone-only lead line (index.html),
   hidden above 600px; the phone media block below flips it on and hides the
   original .hero-lead + eyebrow instead. Declared here (not phone-scoped) so
   it's never visible at any width by accident before the media query loads. */
.hero-lead-phone { display: none; }
.hero-cue-text-phone { display: none; }
.br-phone { display: none; }
.button-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1.75rem; }
.hero-content[aria-hidden="true"] { pointer-events: none; }
.hero-opening { opacity: var(--opening-alpha); transform: translateY(calc(var(--hero-progress) * -3rem)); }
/* Dylan direct (round 13): the opening copy sits in the open road right
   under the logo instead of the shared bottom-anchored .hero-content spot,
   which left the top half empty and crammed everything into the bottom-left
   corner. Compared both at 605-1440px wide: the top placement reads cleaner
   and never competes with the bottom arrow or the start/finish bar. Scoped
   to .hero-opening only, so the later beats (.hero-middle/.hero-start,
   different poses) keep their own positions. */
@media (min-width: 37.5625rem) {
  .hero-opening { top: calc(var(--nav-h, 4.5rem) + 3.5rem); }
}
.hero-middle {
  opacity: var(--middle-alpha);
  transform: translateY(calc((0.28 - var(--hero-progress)) * 4rem));
}
.is-white { color: var(--white); }
/* Your start line is waiting: the one heading that inverts the usual
   gold-highlight pattern, per the client's explicit request. */
/* T3: sits higher than the shared .hero-content bottom offset, so the two
   lines never cover the blue start line painted on the road below her. */
/* H3: base white ("is waiting."), "Your start line" gold - the shared
   .hero-content h2 span rule already colors a span gold, so the markup just
   wraps "Your start line" in a plain span (no .is-white override needed).
   H5: it SLIDES out of the way (not just fades) as she starts to run. */
/* Round 12 (item 2): a little higher again -- was still landing close
   enough to the painted blue start line at 1440x900/1366x768/1280x720 that
   it read as touching, not clearing it. */
.hero-start {
  width: min(34rem, calc(100% - (2 * var(--page-pad)))); bottom: clamp(15rem, 29vh, 21rem);
  opacity: var(--start-alpha);
  transform: translateY(calc((1 - var(--start-alpha)) * 1.25rem)) translateX(calc(var(--start-exit, 0) * -8rem));
}
.text-link {
  display: inline-flex;
  align-items: flex-end;
  min-height: 2.75rem;
  margin-top: 1.5rem;
  padding-bottom: 0.25rem;
  border-bottom: 1px solid currentColor;
  font-weight: 700;
}
.hero-cue {
  position: absolute; z-index: 5; left: 50%; bottom: 4.25rem; transform: translateX(-50%);
  display: flex; align-items: center; gap: 0.9rem; white-space: nowrap; opacity: var(--opening-alpha);
  color: rgba(255, 255, 255, 0.78); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
}
.hero-cue svg { width: 1.1rem; height: 1.1rem; fill: none; stroke: var(--gold); stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; animation: cueBob 1.8s ease-in-out infinite; }
@keyframes cueBob { 0%, 100% { transform: translateY(0); opacity: 0.6; } 50% { transform: translateY(6px); opacity: 1; } }
/* Round 11, item 7b (Dylan direct): a plain bobbing arrow, centered, kept
   through opening/beats/run-lines AND the feature-cards phase (unlike
   .hero-cue's text above, which is opening-only and would read oddly during
   the cards). Hidden by default (and on phone/calm) so it never appears
   outside desktop/tablet film mode -- the phone-lock check for this round
   diffs phone pixel-for-pixel, and a new visible element there would fail
   it outright. --hero-exit (already computed by updateHero, ramps up only
   once she's fully out of frame heading into the pavement zoom) is reused
   to fade this out "when the hero ends" without any new JS variable. */
.hero-scroll-arrow { display: none; }
@media (min-width: 37.5625rem) {
  .hero-scroll-arrow {
    display: flex; position: absolute; z-index: 5; left: 50%; bottom: 4.25rem; transform: translateX(-50%);
    opacity: calc(1 - var(--hero-exit, 0)); pointer-events: none;
  }
  /* Dylan direct: the plain arrow above sits at the exact same centered spot
     as the older .hero-cue text ("Scroll to begin the race") -- both were
     live at once at this width, so the arrow rendered on top of "begin"
     (confirmed live: cue text x=586-855, arrow x=704-736, same y-band).
     One clear affordance reads cleaner than two competing for the same
     pixels; the new plain arrow now carries the whole opening/beats/cards
     span on its own here. Scoped to this same breakpoint so phone's own,
     separately-purposed .hero-cue (its own rewrite further down, "Scroll to
     start race") is completely untouched. */
  .hero-cue { display: none; }
  /* Round 11 follow-up: bigger (was 1.4rem/22.4px -> 2rem/32px) and its own
     slower, deeper bob (2.4s/8px) instead of reusing the shared cueBob
     keyframe (1.8s/6px, still used unchanged by .hero-cue/.races-hint) --
     a dedicated keyframe so this one change doesn't alter those. */
  .hero-scroll-arrow svg {
    width: 2rem; height: 2rem; fill: none; stroke: var(--gold); stroke-width: 2;
    stroke-linecap: round; stroke-linejoin: round; animation: heroArrowBob 2.4s ease-in-out infinite;
  }
}
@keyframes heroArrowBob { 0%, 100% { transform: translateY(0); opacity: 0.6; } 50% { transform: translateY(8px); opacity: 1; } }
.is-calm .hero-scroll-arrow { display: none; }
@media (prefers-reduced-motion: reduce) {
  .hero-scroll-arrow svg { animation: none; }
}
/* She leaves, the camera pushes in, and the scene washes into the race board. */
/* T5: once she is out of frame, a slow zoom on the held empty-asphalt frame
   (--pavement-zoom) on top of the existing hand-off push (--hero-exit). */
.hero-canvas { transform: scale(calc(1 + (var(--hero-exit) * 0.16) + (var(--pavement-zoom, 0) * 0.14))); transform-origin: 50% 40%; }
.hero-exit { position: absolute; z-index: 6; inset: 0; pointer-events: none; background: var(--deep); opacity: var(--hero-exit); }

/* The whole race, start line to finish line, with the roo running it. */
.race-line {
  position: fixed; z-index: 70; left: var(--page-pad); right: calc(var(--page-pad) + 0rem); bottom: 1.1rem;
  display: flex; align-items: center; gap: 1rem; pointer-events: none;
  color: rgba(255, 255, 255, 0.62); font-family: "Space Mono", monospace; font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase;
  opacity: 0; transition: opacity 500ms ease;
}
.race-line.is-on { opacity: 1; }
.race-line::before { content: ""; position: absolute; z-index: -1; left: calc(var(--page-pad) * -1); right: calc(var(--page-pad) * -1); bottom: -1.1rem; height: 4.5rem; background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.78)); }
.race-line-track { position: relative; flex: 1; height: 1px; margin-left: 1.9rem; background: rgba(255, 255, 255, 0.22); }
.race-line-track i { position: absolute; left: 0; top: 0; height: 100%; width: calc(var(--race) * 100%); background: var(--gold); box-shadow: 0 0 10px rgba(212, 167, 44, 0.6); }
.race-line-track img { position: absolute; left: calc(var(--race) * 100%); top: 0; height: 1.5rem; width: auto; transform: translate(-60%, -92%); filter: drop-shadow(0 0 8px rgba(212, 167, 44, 0.55)); }

/* She starts to run: three large left-aligned lines appear one at a time,
   each with a thin gold rule above (Website Blue's launch-lines beat). Once
   she's off frame, each line really morphs (FLIP: translate+scale) into its
   own centered card (photo, number, title, one-line desc, ghost button) --
   see cacheMorphRects/updateHero, not a plain crossfade between two static
   layers. --lines-alpha only fades the line TEXT out early in that morph. */
.hero-lines {
  position: absolute; z-index: 4; left: var(--page-pad); top: 50%; transform: translateY(-50%);
  display: flex; flex-direction: column; gap: 1.6rem;
  opacity: var(--lines-alpha, 0); pointer-events: none;
}
.hero-line {
  display: flex; flex-direction: column; gap: 0.6rem;
  font-family: "Clash Display", Arial, sans-serif; font-weight: 600; font-size: clamp(1.9rem, 4.2vw, 3.4rem);
  line-height: 1.05; text-transform: uppercase; word-spacing: 0.1em; color: var(--white);
  text-shadow: 0 2px 24px rgba(0, 0, 0, 0.6);
  opacity: var(--line-in, 0);
  transform: translateY(calc((1 - var(--line-in, 0)) * 1rem));
}
.hero-line-rule { display: block; width: 3.5rem; height: 2px; background: var(--gold); box-shadow: 0 0 12px rgba(212, 167, 44, 0.6); transform: scaleX(var(--line-in, 0)); transform-origin: left center; }

/* Round 9 (item 2): opacity is no longer gated here -- each card now drives
   its own visibility (JS sets it inline during the FLIP, --card-alpha takes
   over once settled -- see .hero-card and updateHero), so a container-level
   fade would double up with the per-card one and delay the box far past
   when the FLIP transform itself says it should already be visible. */
.hero-cards {
  position: absolute; z-index: 4; inset: 0;
  display: flex; align-items: center; justify-content: center; gap: 1.5rem;
  padding: 0 var(--page-pad);
  pointer-events: none;
}
.hero-cards[aria-hidden="false"] { pointer-events: auto; }
/* transform-origin:0 0 is what makes the FLIP math (translate then scale,
   both in updateHero) land correctly -- with the default center origin the
   same numbers would scale around the box's middle instead of its corner. */
/* Round 11 (item 4): bigger -- together ~83% of viewport width at 1440
   (3 * 24rem + 2 * 1.5rem gap = 1200px / 1440px), was ~66%. */
/* Dylan direct (all widths): text is LOCKED -- number/title/description/
   button never shrink, never clip, never wrap differently because of card
   height. The card is a flex column with a height derived from the
   viewport (clamp: never below what the locked text needs, never more than
   30rem); the photo (flex:1 1 auto; min-height:0) absorbs 100% of that size
   change, shrinking to a thin band or 0 if the viewport is short. The body
   is flex:none (see .hero-card-body) so it always gets its full natural
   content height regardless of the card's own set height -- overflow is
   visible rather than hidden for the same reason: hiding overflow is
   exactly the clipping this is fixing. Stacked layouts (tablet <=900px,
   phone <=600px) override height to flex:1 1 0 (an equal share of the
   stack's own definite container height) instead of this viewport clamp --
   see the 56.25rem and 600px blocks below. */
.hero-card {
  position: relative; isolation: isolate; overflow: visible; display: flex; flex-direction: column;
  width: min(24rem, 27vw); height: clamp(16rem, 56svh, 30rem);
  border-radius: 0.75rem; border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(10, 10, 10, 0.72);
  backdrop-filter: blur(6px);
  transform-origin: 0 0;
  opacity: var(--card-alpha, 0);
  transform: translateY(calc((1 - var(--card-alpha, 0)) * 1.5rem)) translateX(var(--card-out-x, 0)) translateY(var(--card-out-y, 0));
  transition: border-color 200ms ease, box-shadow 200ms ease;
}
.hero-card:hover { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) inset, 0 18px 40px rgba(0, 0, 0, 0.45); }
/* The card's own content (photo + body text) stays hidden through most of
   the FLIP -- it fades in only once the shape is mostly settled into place
   (see cardContentAlpha in updateHero), so nothing stretches or smears
   mid-transform. */
.hero-card-photo, .hero-card-body { opacity: var(--card-content-alpha, 1); transition: opacity 100ms linear; }
.hero-card-photo {
  display: block; flex: 1 1 auto; min-height: 0; height: auto;
  background: var(--photo) center / cover no-repeat; background-position: var(--photo-pos, center);
  border-radius: 0.75rem 0.75rem 0 0; /* the card's own overflow is visible now, so the photo rounds its own top corners */
}
/* flex:none -- the text block never shrinks to make room for the photo;
   the photo (above) absorbs all of it instead. */
.hero-card-body { position: relative; display: flex; flex: none; flex-direction: column; gap: 0.6rem; padding: 1.5rem 1.6rem 1.65rem; }
.hero-card-num { font-family: "Clash Display", Arial, sans-serif; font-size: 1.4rem; font-weight: 700; letter-spacing: 0.04em; color: var(--gold); }
.hero-card-title { font-family: "Clash Display", Arial, sans-serif; font-size: 1.6rem; font-weight: 600; line-height: 1.1; text-transform: uppercase; word-spacing: 0.1em; }
.hero-card-desc { color: var(--muted); font-size: 0.82rem; line-height: 1.4; }
.hero-card-btn {
  align-self: flex-start; margin-top: 0.4rem; padding: 0.5rem 0.9rem;
  border: 1px solid rgba(255, 255, 255, 0.34); border-radius: 999px;
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
  transition: border-color 160ms ease, color 160ms ease;
}
.hero-card:hover .hero-card-btn { border-color: var(--gold); color: var(--gold); }
/* Exit: 01 swipes left, 03 swipes right, 02 (middle) rises and fades in place. */
.hero-card:nth-child(1) { --card-out-x: calc(var(--card-exit, 0) * -60vw); }
.hero-card:nth-child(2) { --card-out-y: calc(var(--card-exit, 0) * -3rem); }
.hero-card:nth-child(3) { --card-out-x: calc(var(--card-exit, 0) * 60vw); }


.section { position: relative; overflow-x: clip; }
.section h2 span, .section-races h2 span, .section-services h2 span { color: var(--gold); }
.races-head .eyebrow, .steps-head .eyebrow, .paths-head .eyebrow, .partner-copy .eyebrow, .blog-head .eyebrow, .newsletter-copy .eyebrow { margin-bottom: 1rem; }
.noscript-bar { margin: 0; padding: 1rem; background: var(--panel); text-align: center; font-size: 0.9rem; }
.noscript-bar a { color: var(--gold); }
/* R2: road-stop-link's svg had no size rule anywhere, so it rendered at the
   browser's 300x150 replaced-element default and squeezed the link text down
   to min-content (one word per line) inside the flex row. */
.arrow-svg, .race-tile-go svg, .path-go svg, .blog-card-link svg, .road-stop-link svg { width: 1rem; height: 1rem; flex: none; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; transition: transform 200ms ease; }

/* ---------- Find your next start line: Website Blue's cards. The race name
   leads; the photo sits faded behind it, so a race without a photo still
   reads. ---------- */
/* ---------- T6: races is now a pinned stage, like Website Blue's season /
   .events-track (site-demo setupStart): vertical scroll inside the pin
   drives the whole card row sideways, reversible since it is a direct
   function of scroll position. Exit swipes the whole view right and fades
   to black (T7). See updateRaces in main.js for the vh budget. ---------- */
/* Round 5: pulled up to overlap the hero's last 100vh and painted over it
   (higher z-index, set above) instead of the hero-stage scrolling away in
   normal flow underneath a separately-entering #races - see the note on
   .hero. Sticky math is unaffected: .races-pin's containing block is this
   section's own (margin-shifted) box, so it still engages exactly when that
   box's top reaches the viewport top. */
/* Round 6: both this and .races-bg below used to paint an opaque black
   background, which showed as a hard-edged solid black band over the
   pavement the instant this section's (margin-shifted) box started
   overlapping the hero -- well before .races-bg-video/-grade have faded in.
   Transparent here: body's own black (see body rule) already backs the page
   once the hero is gone, so nothing is lost, and until then the pavement
   shows through undisturbed and crossfades straight into the legs video. */
/* Round 14 (Dylan item 2): 440vh (was 400vh). The exit is now: cards fade out,
   the running-feet video zooms in, then fades to black (see updateRaces) --
   it needs its own scroll room instead of the old swipe-right. */
.section-races { position: relative; z-index: 2; margin-top: -100vh; height: 440vh; }
.races-pin {
  position: sticky; top: 0; height: 100vh; min-height: 38rem; overflow: hidden;
  opacity: calc(1 - var(--races-exit, 0));
}
.races-bg { position: absolute; inset: 0; z-index: 0; overflow: hidden; }
/* Starts fully transparent (0, not a flat 0.9): before .races-pin actually
   engages, its un-stuck box already overlaps the tail of the hero underneath
   (same reason as the section's own margin-top above), and painting the
   video/grade at any opacity there would recreate the seam one beat earlier.
   updateRaces ramps this in only once the pin's own progress has begun. */
.races-bg-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: var(--races-bg-alpha, 0); transform: scale(var(--races-zoom, 1)); transform-origin: 50% 62%; will-change: transform; }
/* F4: the legs must read clearly full-bleed behind the whole block, so the
   grade only darkens the top (heading) and bottom edges; race tiles carry
   their own opaque panel background and stay legible regardless. */
.races-bg-grade { position: absolute; inset: 0; opacity: var(--races-bg-alpha, 0); background: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.15) 22%, rgba(0, 0, 0, 0.15) 70%, rgba(0, 0, 0, 0.55) 100%); }
.races-head { position: absolute; z-index: 2; top: calc(var(--nav-h) + 2.5rem); left: var(--page-pad); }
/* Round 8 (T3): "pulls in" instead of just appearing -- eyebrow rises first,
   heading follows, each its own rise+fade (--races-eyebrow-in/--races-h2-in
   set in updateRaces), not one flat container fade. */
.races-head .eyebrow {
  opacity: var(--races-eyebrow-in, 1);
  transform: translateY(calc((1 - var(--races-eyebrow-in, 1)) * 1rem));
}
@media (min-width: 37.5625rem) { .races-head .eyebrow { color: var(--white); } }
.races-head h2 {
  opacity: var(--races-h2-in, 1);
  transform: translateY(calc((1 - var(--races-h2-in, 1)) * 1.25rem));
}
/* Round 5: the cards used to render at a flat opacity 1 the instant the pin
   existed, arriving before the (fading-in) heading. Now gated by its own
   --races-track-alpha, which rises after the heading (see updateRaces). */
.races-track {
  position: absolute; z-index: 2; left: var(--page-pad); top: 54%;
  display: flex; gap: 1.25rem; will-change: transform;
  opacity: var(--races-track-alpha, 1);
  transform: translate(calc(var(--races-track-x, 0) * 1px), -50%) translateY(calc((1 - var(--races-track-alpha, 1)) * 1.5rem));
}
.race-tile {
  position: relative; isolation: isolate; overflow: hidden; flex: 0 0 auto;
  display: flex; flex-direction: column; justify-content: flex-end;
  width: min(21rem, 62vw); min-height: 17rem; padding: 1.75rem;
  border: 1px solid var(--rule); border-radius: 0.75rem; background: var(--panel);
  transition: transform 260ms ease, border-color 260ms ease, box-shadow 260ms ease;
}
.race-tile::before {
  content: ""; position: absolute; inset: 0; z-index: -2;
  background: var(--photo) center / cover no-repeat;
  filter: grayscale(0.35); opacity: 0.4;
  transition: opacity 400ms ease, transform 700ms ease;
}
.race-tile::after { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(180deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.9) 72%); }
/* Race tiles show the race's own logo on a white plate instead of an event photo (Josh, 2026-10-03). */
.race-tile.has-logo::before, .race-tile.has-logo::after { display: none; }
.race-tile-logo { display: flex; align-items: center; justify-content: center; height: 6.5rem; margin-bottom: auto; padding: 0.75rem 1rem; border-radius: 0.5rem; background: #fff; }
.race-tile-logo + .race-tile-when { margin-top: 1.25rem; }
.race-tile-logo img { max-width: 100%; max-height: 100%; object-fit: contain; }
.race-tile:hover { transform: translateY(-6px); border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) inset, 0 18px 40px rgba(0, 0, 0, 0.45), 0 0 30px rgba(212, 167, 44, 0.14); }
.race-tile:hover::before { opacity: 0.58; transform: scale(1.04); }
.race-tile-when { margin-bottom: 0.75rem; color: var(--gold); font-family: "Space Mono", monospace; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.race-tile-name { font-size: clamp(1.5rem, 2.3vw, 2.2rem); line-height: 1.02; }
.race-tile-with { margin-top: 0.6rem; color: var(--muted); font-size: 0.9rem; line-height: 1.45; }
.race-tile-go { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 2.75rem; margin-top: 0.9rem; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.race-tile:hover .race-tile-go { color: var(--gold); }
.race-tile:hover .race-tile-go svg { transform: translateX(4px); }
.race-tile-add { border-color: rgba(212, 167, 44, 0.45); }
/* T4: ghost button -- transparent, thin low-opacity border, fills gold only
   on hover/focus. Sits between the boxes and the scroll hint below them. */
.races-view-all {
  position: absolute; z-index: 2; left: 50%; bottom: 7rem; transform: translateX(-50%);
  padding: 0.7rem 1.5rem; border: 1px solid rgba(212, 167, 44, 0.35); border-radius: 999px;
  color: rgba(255, 255, 255, 0.78); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  opacity: var(--races-track-alpha, 1); transition: background-color 200ms ease, border-color 200ms ease, color 200ms ease;
}
.races-view-all:hover, .races-view-all:focus-visible { background: var(--gold); border-color: var(--gold); color: var(--black); }
/* Round 11, item 2: bottom-LEFT (was centered), a gold chevron on each side
   of the text (index.html has two <svg> now, one before/one after the
   span). Desktop/tablet only in effect -- .races-hint is display:none on
   phone (see the phone block), so this repositioning never reaches phone. */
/* Round 12, item 8: hidden by default (desktop/tablet/calm never render
   this -- desk-lock's snapshot walk skips display:none nodes, so this is
   safe against both locks), shown/positioned only in the phone block. */
.road-cue { display: none; }
.races-hint {
  position: absolute; z-index: 2; left: var(--page-pad); bottom: 3rem; transform: none;
  display: flex; align-items: center; gap: 0.75rem;
  color: rgba(255, 255, 255, 0.78); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
  opacity: var(--races-hint-alpha, 1); transition: opacity 300ms ease;
}
.races-hint svg { width: 1rem; height: 1rem; fill: none; stroke: var(--gold); stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; animation: cueBob 1.8s ease-in-out infinite; }

/* ---------- T7: services is pinned too. Races leaves by swiping the whole
   view right and fading to black; this stage starts on that same black and
   stages its own reveal: heading, then the four steps one at a time, held
   once all four are in, then it releases into the road section. See
   updateServices in main.js for the vh budget. ---------- */
/* Round 13 (item 3): this used to also carry the SAME pavement.jpg as a
   background (cover-cropped on the whole 280vh box, a different crop than
   .road-bg's own -- see index.html). Once .services-pin actually slides
   fully off-screen (Round 12's -120vw exit), that left a gap where THIS
   section's own mismatched pavement crop peeked through underneath, well
   before road's own (properly gated, correctly cropped) .road-bg had
   risen -- a hard-edged, wrongly-positioned "preview" rectangle. Flat black
   now; the real pavement reveal is road's own .road-bg only. */
/* Round 11 follow-up: had no overlap at all with races' own release tail
   (every other hand-off on the page uses this same -100vh pattern -- road
   over steps, partners over road), so once races faded out there was a
   full extra viewport of plain scroll before steps' own pin even engaged --
   measured as a ~800-1000px fully-black gap at 1440x900. -100vh pulls
   steps' pin up to start right where races releases, so the entrance swipe
   (--services-enter) now overlaps races' own fade-out instead of following
   a dead gap. */
/* Round 14 (item 2): was background:var(--black) -- the box itself rose over
   races as a black scroll-up wipe. Transparent now; the pin is opacity 0 until
   it engages (--services-enter), and body black backs it, so the only black
   the viewer sees is the one races fades down to. */
.section-services { position: relative; z-index: 2; height: 280vh; margin-top: -100vh; }
/* Round 11 (item 5): sits a bit lower -- extra top padding (more room above
   the eyebrow) shifts the centered block down within the pin, and the extra
   bottom padding gives the marquee room instead of sitting flush at the edge. */
/* Round 12 (item 3): the asymmetric 7/3.5rem padding was sized to leave the
   marquee room at the bottom -- now that the marquee has moved to Partners
   (A2), that read as the heading+steps floating high with dead space below.
   Back to a near-symmetric pad so centered-in-the-pin means centered. */
.services-pin {
  position: sticky; top: 0; height: 100vh; min-height: 38rem; overflow: hidden;
  display: flex; flex-direction: column; align-items: flex-start; justify-content: center;
  gap: 3rem; padding: 5rem var(--page-pad);
  /* Round 11, item 3: --services-enter (0 -> 1 over the first 12vh) adds a
     swipe-in-from-the-left to the existing exit fade -- multiplied together
     so entrance and exit share one opacity value without fighting. */
  opacity: calc((1 - var(--services-exit, 0)) * var(--services-enter, 1));
  /* Round 12 (item 4): title, rule and the four step boxes swipe LEFT
     together (was a scale-zoom on this same --services-exit tail) -- a
     second, earlier value (--services-content-exit) drives the swipe,
     off-canvas at -120vw so nothing lingers; --services-exit still just
     fades this pin's opacity for the later crossfade into #road/pavement.
     Round 11 (item 3) adds the entrance swipe on the same axis: starts at
     -30vw and settles to 0 as --services-enter reaches 1. */
  transform: translateX(calc(var(--services-content-exit, 0) * -120vw)); /* Round 14: entrance is a fade out of black now, no swipe-in */
}
.services-pin::before { content: ""; position: absolute; inset: 0; z-index: 0; background: rgba(0, 0, 0, 0.86); }
.steps-head { position: relative; z-index: 1; opacity: var(--steps-head-alpha, 0); transform: translateY(calc((1 - var(--steps-head-alpha, 0)) * 1.25rem)); }
.steps-route { position: relative; z-index: 1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; width: 100%; }
/* Round 11 (item 6): the rule used to have no exit of its own, staying at
   full opacity even after the title and steps had faded/swiped away. Fades
   with the title (same --steps-head-alpha). */
.steps-route::before {
  content: ""; position: absolute; left: 1.75rem; right: 1.75rem; top: 3.25rem; height: 1px;
  background: rgba(212, 167, 44, 0.45);
  opacity: var(--steps-head-alpha, 0);
}
/* T7: exits to the left, staggered 04 -> 01 (see updateServices). */
.step-card {
  position: relative; min-height: 14rem; padding: 1.5rem;
  border: 1px solid var(--rule); border-radius: 1rem; background: rgba(10, 10, 10, 0.94);
  opacity: calc(var(--step-in, 0) * (1 - var(--step-exit, 0)));
  transform: translateY(calc((1 - var(--step-in, 0)) * 2rem)) scale(calc(0.94 + (var(--step-in, 0) * 0.06))) translateX(calc(var(--step-exit, 0) * -4rem));
  transition: border-color 300ms ease, box-shadow 300ms ease, transform 300ms ease;
}
/* T7: hover lift plus a gold glow on the border and the number circle. */
.step-card:hover { border-color: var(--gold); transform: translateY(-6px) scale(1); box-shadow: 0 0 0 1px var(--gold) inset, 0 18px 40px rgba(0, 0, 0, 0.45), 0 0 30px rgba(212, 167, 44, 0.14); }
.step-num {
  position: relative; z-index: 1; display: grid; place-items: center; width: 3.5rem; height: 3.5rem; margin-bottom: 2.5rem; border-radius: 50%;
  background: var(--gold); color: var(--black); font-family: "Clash Display", Arial, sans-serif; font-size: 1.1rem; font-weight: 700;
  transition: box-shadow 300ms ease;
}
.step-card:hover .step-num { box-shadow: 0 0 0 4px rgba(212, 167, 44, 0.28), 0 0 22px rgba(212, 167, 44, 0.55); }
.step-card h3 { font-size: clamp(1.4rem, 2.2vw, 2rem); }
.step-card p { margin-top: 0.75rem; color: var(--muted); font-size: 0.95rem; line-height: 1.5; }
/* Round 1 (mobile pass): gold tint on hover, and a CTA under the four steps. Film mode fades the
   button in with the last step (--steps-cta-in, set in updateServices); calm shows it at rest. */
.step-card:hover { background-image: linear-gradient(rgba(212, 167, 44, 0.1), rgba(212, 167, 44, 0.1)); }
.steps-cta { position: relative; z-index: 1; opacity: var(--steps-cta-in, 0); transform: translateY(calc((1 - var(--steps-cta-in, 0)) * 1rem)); }
.is-calm .steps-cta { opacity: 1; transform: none; margin-top: 2rem; }

/* ---------- Every detail earns its place: the pinned road. Deliberately NOT
   a .scene (no transform wrapper), so position:sticky keeps working. Stops
   drive in from alternating sides as .road-pin's own scroll progress advances;
   see updateRoad in main.js. ---------- */
/* Reviewer round 2 (P1): stack above #partners, which now overlaps this
   section's own structural "slide away" tail (see the note in updateRoad in
   main.js). road-pin fades its own opacity via --road-exit instead of a
   black overlay, so the already-lit partner pin crossfades in underneath. */
/* Same fix as #about below: the opaque background must live on the pin
   itself, not the section, or fading the pin's opacity just reveals the
   section's own background instead of #partners underneath. */
/* Round 11 (item 6): overlaps services' own structural "slide away" tail
   (same -100vh pattern as races/road/partner/about) so leaving the steps is
   a real crossfade+zoom into the road/pavement instead of a plain scroll
   cut: the yellow rule and title fade out (see updateServices), the pin
   itself fades via --services-exit, and the road's own bg zooms in from
   black (--road-bg-in, see updateRoad) underneath. */
/* Round 11, item 4: -140vh (was -100vh) so the road pin engages, and its own
   --road-bg-in fade starts, a full extra 40vh earlier -- overlapping the
   TAIL of the steps section's own swipe-left-and-out exit instead of only
   starting once steps has fully left. */
.section-road { position: relative; z-index: 4; height: 460vh; margin-top: -115vh; }
/* Round 11 (item 6): road now overlaps services' own tail (margin-top:-100vh
   on .section-road), so this can't paint an opaque background any more --
   same lesson as Round 6's races fix: an opaque color here would paint a
   hard-edged rectangle over services the instant the overlap begins,
   regardless of --road-bg-in's own fade. Body's own black backs it instead. */
.road-pin { position: sticky; top: 0; height: 100vh; min-height: 38rem; overflow: hidden; opacity: calc(1 - var(--road-exit, 0)); }
/* Round 12 (item 4): "the street/pavement slowly fades in UPWARD (rises
   from the bottom)" -- was a scale-up-from-black zoom (Round 11); now a
   translateY rise (starts 8vh below, settles to 0) + fade, over the pin's
   first stretch (--road-bg-in, see updateRoad), instead of just appearing
   at full size/opacity/position the instant the pin engages. The ongoing
   road-progress zoom (stops driving past) is unrelated and untouched. */
/* Round 13 (item 3): full-bleed from the start of the rise (inset was
   already -12% 0, i.e. full viewport width, unchanged) with a soft top
   mask so the rise itself never reads as a hard rectangle edge -- belt and
   suspenders alongside removing the duplicate pavement bg (Round 13 note on
   .section-services) that was the actual edge's real cause. */
.road-bg {
  position: absolute; inset: -12% 0; z-index: 0; background-size: cover; background-position: center;
  opacity: var(--road-bg-in, 1);
  transform: translateY(calc((1 - var(--road-bg-in, 1)) * 8vh)) scale(calc(1 + (var(--road-progress, 0) * 0.12)));
  mask-image: linear-gradient(to bottom, transparent, #000 12%);
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 12%);
}
/* Round 8 (Dylan direct): this had no opacity of its own (unlike .road-bg
   and .road-lines right below, both already gated to --road-bg-in per the
   Round 13 notes above) -- so once .section-road's own -100vh overlap began,
   this gradient's full opacity painted immediately, with its own inset:0 box
   edge as a hard top line, sweeping up over the steps boxes as the overlap
   progressed (reported: "a dark hue with a hard top edge moving up over
   boxes 03 and 04"). Same fade-in fix as its siblings. */
/* Round 14 (item 3): softened (.55/.35/.72 -> .22/.14/.42): the heading over the
   pavement was getting lost under the heavy gradient; with the lighter tint
   plus the text-shadow on .road-head the title reads clearly. */
.road-tint { position: absolute; inset: 0; z-index: 0; background: linear-gradient(180deg, rgba(0, 0, 0, 0.22), rgba(0, 0, 0, 0.14) 50%, rgba(0, 0, 0, 0.42)); opacity: var(--road-bg-in, 1); }
/* Round 13 (item 3): had no opacity of its own, so the lane lines were
   fully visible immediately, before the pavement itself had risen in
   (--road-bg-in). Shares that same fade now. */
.road-lines { position: absolute; inset: 0; z-index: 1; pointer-events: none; opacity: var(--road-bg-in, 1); }
.road-edge { position: absolute; top: 0; bottom: 0; width: 3px; background: rgba(255, 255, 255, 0.4); }
.road-edge-left { left: 4%; }
.road-edge-right { right: 4%; }
.road-center {
  position: absolute; top: 0; bottom: 0; left: 50%; width: 5px; margin-left: -2.5px;
  background: repeating-linear-gradient(to bottom, rgba(212, 167, 44, 0.75) 0 4.5rem, transparent 4.5rem 9rem);
  background-position-y: calc(var(--road-progress, 0) * -30rem);
}
/* T8: confined to well within the left lane, so it never reaches the
   yellow center line at 1440 or 1280 (checked via getBoundingClientRect -
   a 56rem max-width reached to x=953 at 1440 while the line sits at
   x=717-722). Round 8 (T7 tail): now also pulls in on its own entrance
   (--road-head-in, set by updateRoad) as well as fading out later. */
.road-head {
  position: absolute; top: calc(var(--nav-h) + 2rem); left: var(--page-pad); z-index: 2; max-width: min(30rem, 42vw);
  opacity: calc(var(--road-head-in, 1) * (1 - var(--road-head-out, 0)));
  transform: translateY(calc((1 - var(--road-head-in, 1)) * 1.5rem));
}
/* Sized so "Every detail earns" fits the one line the <br> in the markup
   expects, inside the head's min(30rem, 42vw) box, without wrapping to a
   third line (checked at 1440/1366/1280 - see the .road-head comment). */
.road-head h2 { font-size: clamp(1.8rem, 3vw, 2.6rem); }
/* Round 14 (item 3): centered in the left lane, i.e. between the white edge line
   (left:4%) and the yellow center line (50%), instead of left-aligned. Film
   mode above the 44rem layout break only; phone and calm keep their own rules. */
@media (min-width: 44.0625rem) {
  html:not(.is-calm) .road-head {
    left: calc(4% + 3px); width: calc(46% - 5.5px); max-width: none; text-align: center;
    text-shadow: 0 2px 6px rgba(0, 0, 0, 0.7), 0 2px 28px rgba(0, 0, 0, 0.75);
  }
}
.road-head h2 span, .road-box-cta h3 span { color: var(--gold); }
/* Round 11 (Dylan direct, item 7): back to one-at-a-time, alternating
   drive-in from the side (like the original road stops) for FILM mode --
   the Round 8 2x2 grid (--box-in accumulate-all-four) is calm-mode only now
   (see .is-calm .road-grid below). .road-grid is just a positioning
   passthrough here: .road-box positions itself against .road-pin, same as
   the pre-T8 .road-stop did. */
.road-grid { position: static; }
.road-box {
  /* max(50%, 29rem): centered on tall viewports, but never higher than a
     fixed floor that keeps every box's top edge below the heading's bottom
     edge (checked at 1440x900, 1366x768 and 1280x720). */
  position: absolute; top: max(50%, 29rem); z-index: 3;
  /* Dylan round 6, DESKTOP item 6: ~9% bigger (36rem/42vw -> 39rem/44vw),
     checked it still fits two side by side at 1366x768 and 1280x720
     (max-width below still caps the worst case). */
  width: min(39rem, 44vw); max-width: calc(100vw - (2 * var(--page-pad)) - 2rem);
  border-radius: 0.75rem; overflow: hidden;
  border: 1px solid var(--rule); background: var(--panel);
  /* Round 13 (item 2): opacity is --box-opacity (steep, only ramps in the
     last 15% of travel -- see updateRoad), not the same linear --box-in
     that still drives the slide below. Panel, photo and text all still
     share this ONE opacity and this ONE transform on the box container --
     just the opacity value itself is shaped differently from the position
     value now, on purpose (see updateRoad's comment for why). */
  opacity: var(--box-opacity, 0);
  transform: translateY(-50%) translateX(calc((1 - var(--box-in, 0)) * var(--box-side, 1) * 60vw));
  transition: border-color 200ms ease, box-shadow 200ms ease;
}
.road-box.is-left { left: var(--page-pad); --box-side: -1; }
.road-box.is-right { right: var(--page-pad); --box-side: 1; }
.road-box:hover { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) inset, 0 18px 40px rgba(0, 0, 0, 0.45); }
/* The photo fills the box, dark grade strongest behind the text for
   legibility (T8), brighter on hover. Same treatment as the Partners foot
   film - the photo reads, but the text stays the focus. */
.road-stop-photo { position: absolute; inset: 0; z-index: -1; background: var(--photo) center / cover no-repeat; background-position: var(--photo-pos, center); opacity: 0.55; transition: opacity 400ms ease; }
.road-stop-photo::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(100deg, rgba(10, 10, 10, 0.4), rgba(10, 10, 10, 0.72) 55%, rgba(10, 10, 10, 0.88)),
              linear-gradient(180deg, rgba(10, 10, 10, 0.3), transparent 30%, transparent 55%, rgba(10, 10, 10, 0.95));
}
.road-box:hover .road-stop-photo { opacity: 0.7; }
/* Round 11: roomier again now that only one box is on screen at a time in
   film mode (was compacted in Round 8 for the 2x2 grid's 4-at-once squeeze;
   that compaction is kept below for short heights and for the calm grid). */
.road-stop-body { position: relative; display: flex; flex-direction: column; height: 100%; padding: 1.85rem 1.85rem 2.1rem; }
.road-stop-num {
  display: block; margin-bottom: 0.75rem;
  /* Dylan round 6, DESKTOP item 6: type bumped ~9% along with the box. */
  font-family: "Clash Display", Arial, sans-serif; font-size: 2.75rem; font-weight: 700; line-height: 1;
  color: var(--gold); text-shadow: 0 2px 20px rgba(0, 0, 0, 0.6);
}
.road-stop-tag {
  display: inline-block; width: fit-content; margin-bottom: 1rem; padding: 0.35rem 0.8rem;
  border: 1px solid rgba(212, 167, 44, 0.6); border-radius: 999px;
  color: var(--gold); font-size: 0.74rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
}
.road-stop-body h3 { font-size: clamp(1.5rem, 2.3vw, 2.05rem); margin-bottom: 0.85rem; }
.road-stop-body ul { display: grid; gap: 0.4rem; margin: 0 0 1.1rem; padding: 0; list-style: none; }
.road-stop-body li { position: relative; padding-left: 1rem; color: var(--muted); font-size: 0.88rem; }
.road-stop-body li::before { content: ""; position: absolute; left: 0; top: 0.6rem; width: 0.5rem; height: 1px; background: var(--gold); }
.road-stop-link { display: inline-flex; align-items: center; gap: 0.5rem; margin-top: auto; min-height: 2.75rem; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); transition: color 200ms ease; }
.road-stop-link:hover, .road-box:hover .road-stop-link { color: var(--gold); }
.road-stop-link:hover svg { transform: translateX(4px); }
/* Box 04: the whole-crew message, own copy and buttons instead of a bullet list. */
.road-box-cta .road-stop-body p:not(.road-stop-tag) { margin: 0 0 1rem; color: var(--muted); font-size: 0.92rem; line-height: 1.45; }
.road-box-cta .button-row { margin-top: auto; gap: 0.75rem; }
/* The last stretch of the road pin fades to black (updateRoad sets --road-exit),
   so the seam into the partner pin's own black-to-image crossfade is soft
   rather than a hard cut. Same pattern as .hero-exit: a full-screen wash on
   top of everything, because the sticky pin's own final ~1 viewport of
   scroll (after its progress has already reached 1) slides the CTA content
   itself out of view vertically, so fading it under black together with the
   background reads as one continuous darken instead of content vanishing
   and then a separate black gap. */
.chip-ticker { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 1.1rem; }
.chip-ticker-label { font-size: 0.6rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--muted); font-weight: 700; }
.chip-ticker-digits { display: inline-flex; align-items: baseline; font-family: "Clash Display", Arial, sans-serif; font-weight: 700; font-size: 2.1rem; line-height: 1; color: var(--gold); font-variant-numeric: tabular-nums; }
.tick { display: inline-block; height: 1em; overflow: hidden; vertical-align: baseline; }
.tick-strip { display: block; animation: tickRoll var(--dur, 1s) steps(10) infinite; }
.tick-strip i { display: block; height: 1em; line-height: 1; font-style: normal; }
@keyframes tickRoll { to { transform: translateY(-10em); } }

/* ---------- Partner with us: a pinned, scroll-scrubbed footfall. Fades in
   with only the marquees on screen; the photo and copy pop in midway and
   hold while the stage slowly zooms; everything but the marquees fades out
   as the foot finishes. See updateFoot in main.js for the progress bands. ---------- */
/* Reviewer round 2 (P1): pulled up under the road's own structural tail
   (one viewport) with a negative margin, sitting behind it (lower z-index),
   so the road fading its own opacity away crossfades directly onto the
   already-scrubbing foot film instead of both going to black in sequence. */
/* z-index 2, not 1: #finishFilm (z1) shares no overlap with #partners by
   design, but they used to share the SAME z-index, so wherever finishFilm's
   own (widened) overlap accidentally reached back this far, DOM order (it
   comes later) let its box paint over partner's instead of staying behind
   it. Keeping every pinned section's z strictly ordered by page position
   (road 4 > about 3 > partner 2 > finish 1) means whichever one is earlier
   on the page is always on top, regardless of how far any overlap reaches -
   no more DOM-order tie-breaks to reason about. */
/* Round 5: grown from 380vh -- the new timeline in updateFoot() (main.js)
   adds the marquee's own rise+hold(~70vh)+fade(~30vh) phases after the
   existing entrance/hold/bullets-out/rest-out ones, all in real vh; 440vh
   (distance 340vh at 100vh pin) covers the full ~296vh schedule with real
   buffer left before progress clamps to 1, same margin every other pinned
   section keeps before its own structural tail takes over. */
.partner { position: relative; z-index: 2; height: 440vh; margin-top: -100vh; background: var(--black); }
.partner-pin {
  position: sticky; top: 0; height: 100vh; min-height: 38rem; overflow: hidden;
  /* P5: softens the marquee band's own bottom edge as the pin exits. */
  mask-image: linear-gradient(to bottom, #000 88%, transparent);
  -webkit-mask-image: linear-gradient(to bottom, #000 88%, transparent);
}
/* Phase 2 (mobile spec, item 3): #partnerMediaBand/#partnerMediaSticky exist
   only for phone's own sticky media+band stage (see the phone block at the
   end of this file) -- display:contents removes both wrappers from the box
   tree everywhere else, so .partner-stage's position:absolute keeps
   resolving against #partnerPin exactly as it always has. Zero effect above
   600px or in calm mode. */
.partner-media-band, .partner-media-sticky { display: contents; }
.partner-stage {
  position: absolute; inset: 0; z-index: 0;
  transform: scale(var(--partner-zoom, 1)); transform-origin: 50% 55%;
  /* Fades up from black as the pin begins (see --partner-enter in updateFoot),
     so the hand-off from the road's own black exit has no hard edge. */
  opacity: var(--partner-enter, 1);
}
.partner-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.partner-grade {
  position: absolute; inset: 0;
  background: linear-gradient(90deg, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0.45) 55%, rgba(0, 0, 0, 0.65)),
              linear-gradient(180deg, rgba(0, 0, 0, 0.6), transparent 30%, transparent 62%, rgba(0, 0, 0, 0.85));
  /* Dark only while the words sit on screen; clear while the footfall runs. */
  opacity: calc(0.12 + (var(--tint, 0) * 0.88));
}
/* Round 5 (A2 item 2): the marquee band lives at the bottom of this same
   stage now (see .service-marquee below), so the grid's bottom padding grew
   from 3rem to 9rem to reserve that band -- align-items:center then does the
   rest, shifting the centered photo/copy content up on its own. */
/* Round 11 (A2 item 7): back to a plain 2-col grid -- photo spans the full
   left column, title+bullets+buttons stack together in the right column
   (title ABOVE the bullets, not spanning full width above both columns like
   round 8 had it). #partnerCopy is the single grid item for "details" now
   (no longer display:contents / no longer split into two named areas);
   .partner-heading and .partner-details flow normally inside it, one after
   the other, same as any other stacked content. text-shadow on
   .partner-copy still applies to both since they're real descendants again. */
.partner-grid {
  position: absolute; z-index: 1; inset: 0; display: grid; align-content: center;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
  grid-template-areas: "photo details";
  column-gap: clamp(2rem, 5vw, 4rem);
  /* Horizontal page-pad kept from round 8 (the old justify-self:end trick
     that used to keep the photo off the left edge doesn't apply with
     justify-self:start below). Bottom reserve keeps the same clearance
     above the marquee established in round 8 -- the single-row grid here
     has strictly MORE headroom than round 8's auto+1fr split did (no
     separate title row eating into it), so the same 34px extra margin is
     comfortably safe, if anything more so. */
  padding: calc(var(--nav-h) + 1.5rem) var(--page-pad) calc(9rem + 34px);
}
.partner-copy { grid-area: details; align-self: center; }
.partner-image {
  grid-area: photo; align-self: center;
  /* Round 4 (item 1): 30rem/1440px was ~33vw; Dylan wants it a bit smaller
     so it doesn't consume the screen -- 28rem/1440px is ~31vw, mid-range of
     the requested 30-32%. max-height:100% (new) caps it to its own grid
     row -- see the row-sizing note on .partner-grid above -- so the aspect
     ratio now derives WIDTH from whichever is smaller, the 34vw/28rem cap
     or the row's real available height, instead of always taking the full
     width cap and letting height run wherever the 4:5 ratio put it. */
  justify-self: start; width: min(100%, 34vw); max-width: 28rem; max-height: 100%; aspect-ratio: 4 / 5; overflow: hidden; /* Round 14 (item 4): shifted right on desktop/tablet film, see the min-width block below */
  border: 1px solid var(--rule); border-radius: 0.75rem; box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55);
  opacity: var(--partner-in, 0);
  transform: translateY(calc((1 - var(--partner-in, 0)) * 1.5rem));
  transition: opacity 700ms ease, transform 700ms ease;
}
.partner-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 900ms ease; }
.partner-image:hover img { transform: scale(1.04); }
.partner-copy { text-shadow: 0 2px 24px rgba(0, 0, 0, 0.55); }
/* Round 11 (A2 item 7): title now has its OWN stage (--partner-title-in),
   separate from the photo (--partner-in) -- part of the photo -> title ->
   bullets -> buttons sequence. .partner-details is a plain layout wrapper
   now (no opacity of its own); bullets (--li-o, per <li>, in updateFoot)
   and the button row (--partner-btn-in, just below) each drive their own
   entrance/exit independently instead of the old two-group split. */
.partner-heading {
  opacity: var(--partner-title-in, 0);
  transform: translateY(calc((1 - var(--partner-title-in, 0)) * 1.5rem));
  transition: opacity 700ms ease, transform 700ms ease;
}
/* B1: "Partner" gold, "with us." white -- the inverse of the usual pattern,
   so the span (normally gold via .section h2 span) wraps the FIRST word
   here instead of the highlighted phrase. */
.partner-heading { font-size: clamp(2rem, 4.2vw, 3.75rem); }
/* Round 13 (Dylan direct): desktop/tablet only -- title much bigger and the
   whole right column centered between the photo and the page edge, instead
   of hugging the photo left-aligned. Phone keeps its own layout (its phone
   block overrides font-size/alignment). */
@media (min-width: 37.5625rem) {
  .partner-copy { justify-self: center; text-align: center; }
  /* Round 14 (item 4): photo sits right in its column, closer to the copy. */
  html:not(.is-calm) .partner-image { justify-self: end; }
  .partner-heading { font-size: clamp(2.8rem, 6.4vw, 6.25rem); line-height: 0.95; }
  .partner-list { margin-left: auto; margin-right: auto; text-align: left; }
  .partner-details .button-row { justify-content: center; }
}
.partner-details { opacity: 1; }
/* Buttons are their own, later stage after the bullets have all finished
   entering. Scoped to film mode only (:not(.is-calm)) so calm's own
   handling of this button row (item 14, a separate item this round) is
   completely independent -- this rule simply doesn't match there. */
.js:not(.is-calm) .partner-details .button-row {
  opacity: var(--partner-btn-in, 0);
  transform: translateY(calc((1 - var(--partner-btn-in, 0)) * 1rem));
  transition: opacity 700ms ease, transform 700ms ease;
}
.partner-list { max-width: 32rem; margin: 1.1rem 0; padding: 0; list-style: none; }
.partner-list li {
  display: flex; align-items: center; gap: 1rem; padding: 0.6rem 0; border-top: 1px solid var(--rule);
  font-family: "Clash Display", Arial, sans-serif; font-size: 1.05rem; font-weight: 600; text-transform: uppercase; word-spacing: 0.12em;
  transition: padding-left 250ms ease, color 200ms ease;
}
.partner-list li:last-child { border-bottom: 1px solid var(--rule); }
.partner-list li::before { content: ""; flex: 0 0 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--gold); }
/* B1: hovering a bullet turns its text gold. */
.partner-list li:hover { padding-left: 0.5rem; color: var(--gold); }
/* Round 4 (item 1): was a time-based `listIn` keyframe with `forwards` fill,
   which -- once it finished -- outranked any later inline opacity from JS
   (an animation's held forwards-fill value still wins the cascade over an
   author style/inline rule for that property). The new exit ("bullets fade
   out one by one before the rest leaves") needs JS to keep controlling this
   after entrance, so it is a continuous scroll-driven var instead, same
   pattern as --el-o on the founders heading. */
.js .partner-list li { opacity: var(--li-o, 0); transform: translateX(calc((1 - var(--li-o, 0)) * 2.5rem)); }
/* Round 5 (A2 items 1/3): moved back to being pinned to the bottom of the
   Partners stage (was here originally pre-Round 8, then lived under the
   steps section as a normal flex child for a few rounds -- now back, this
   time with a real exit). Visible for the whole pin (no entrance fade, same
   as its steps-section incarnation), rises a little once it's the only
   thing left on screen, holds, then fades before founders engages. See
   updateFoot() in main.js for --marquee-rise/--marquee-alpha. */
.service-marquee {
  position: absolute; z-index: 1; left: 0; right: 0; bottom: 1.75rem; padding: 0 var(--page-pad);
  opacity: var(--marquee-alpha, 1);
  /* Round 13 (Dylan direct): locked at the bottom in its own band. The old
     scroll-driven rise (--marquee-rise, up to -8rem) slid it up over the
     partner photo; nothing may ever sit behind or above it, so no rise. */
  transform: none;
}
/* Round 8 fix: text-align:center only centers the glyphs -- the <p> itself
   was still a full-width block, so its OWN bounding box reached the right
   edge (into the chat bubble's zone) even though the visible text sits well
   clear of it in the middle. width:fit-content makes the box match what's
   actually there instead of the full row. */
.marquee-eyebrow { margin: 0 auto 0.85rem; text-align: center; width: fit-content; }
/* B1: shorter desktop heights (1366x768, 1280x720) get a further trim so
   the copy and buttons fit -- no marquee to clear any more (T6 moved it
   under steps), so this is just a font/spacing trim, not a layout squeeze.
   Photo keeps its ~40vw B1 size; only the tighter clamp() upper end differs. */
@media (min-width: 68rem) and (max-height: 800px) {
  .partner-grid { padding: calc(var(--nav-h) + 0.5rem) var(--page-pad) calc(8rem + 48px); }
  .partner-heading { font-size: clamp(2.6rem, 5.2vw, 4.5rem); }
  .partner-list { margin: 0.75rem 0; }
  .partner-list li { padding: 0.4rem 0; }
  .partner-image { width: min(100%, 27vw); }
  /* Coordinator gate fix: on this compact height the button-row sits
     closer to the marquee's resting spot than on desktop (less vertical
     room overall), so the full 8rem rise (tuned for tall viewports) swings
     the marquee up through "Partner with us" while it's still fading out --
     measured live at 1280x720: marquee at opacity 1 overlapping the button
     at ~0.43 opacity around scrollY 16000. Same crossfade, shorter travel:
     3rem clears the button with room to spare at every point along the
     shared rise/fade progress, checked at 1366x768 and 1280x720. */
  .service-marquee { transform: none; }
  /* Round 9 (item 4), retimed Round 11 (item 7), kept per Round 12 (item 5,
     Dylan direct: "keep the bullets; only drop the chip ticker if needed").
     Compact type/padding, drop only the chip ticker below this height --
     the bullets now stay, just tighter, and still fit (checked at 768/720:
     even the 5-bullet box lands well inside the box's own vertical room).
     (.road-grid itself no longer needs an override here -- film mode
     positions .road-box directly against .road-pin, not the grid, since
     Round 11 reverted the 2x2 layout to calm-only.) */
  .road-stop-body { padding: 0.85rem 1rem; }
  .road-stop-num { font-size: 1.3rem; margin-bottom: 0.25rem; }
  .road-stop-tag { margin-bottom: 0.4rem; padding: 0.22rem 0.55rem; font-size: 0.56rem; }
  .road-stop-body h3 { font-size: clamp(0.9rem, 1.3vw, 1.1rem); margin-bottom: 0.3rem; }
  .chip-ticker { display: none; }
  .road-stop-body ul { gap: 0.15rem; margin-bottom: 0.5rem; }
  .road-stop-body li { font-size: 0.7rem; padding-left: 0.85rem; }
  .road-stop-link { min-height: 1.75rem; font-size: 0.68rem; }
  .road-box-cta .road-stop-body p:not(.road-stop-tag) { font-size: 0.74rem; margin-bottom: 0.5rem; }
  .road-box-cta .button-row { gap: 0.4rem; }
  .road-box-cta .button { padding: 0.5rem 0.8rem; font-size: 0.68rem; }
}
.marquee-row {
  overflow: hidden; white-space: nowrap; margin-bottom: 0.5rem;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
  mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
}
.marquee-track { display: inline-flex; animation: marqueeLeft 40s linear infinite; }
.marquee-row[data-dir="right"] .marquee-track { animation-name: marqueeRight; }
.marquee-row:hover .marquee-track { animation-play-state: paused; }
@keyframes marqueeLeft { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@keyframes marqueeRight { from { transform: translateX(-50%); } to { transform: translateX(0); } }
.marquee-item { padding: 0 0.5rem; color: rgba(255, 255, 255, 0.34); font-family: "Clash Display", Arial, sans-serif; font-size: clamp(1.1rem, 1.8vw, 1.5rem); font-weight: 600; text-transform: uppercase; transition: color 200ms ease; }
.marquee-item:hover { color: var(--white); }
.dot { display: inline-block; width: 4px; height: 4px; margin: 0 0.9rem; border-radius: 50%; background: var(--gold); vertical-align: middle; }


/* Founders: reviewer round 2 (P2). A short pin (200vh section, 100vh sticky
   inner) so the exit can structurally shift the block left and fade it,
   rather than relying on a continuous scroll-linked scene transform. The
   pin's own opacity fades via --about-tail over its structural post-
   progress-1 tail (see the note in updateRoad), revealing #finishFilm --
   which overlaps that exact tail the same way #partners overlaps the road's. */
/* The section itself must stay transparent: it used to carry the black
   background, but since about-pin always fills the full viewport whenever
   any part of #about is in view (that's what the sticky pin does), the
   section's own opaque bg sat BEHIND about-pin at the same z-index stack,
   fully blocking finishFilm the instant about-pin's own opacity dropped
   below 1 -- the fade revealed the section's own black, never the reveal
   underneath. The background belongs on the pin itself now, so fading the
   pin's opacity actually exposes what's behind it. */
/* Round 3 (item 2): overlaps #partners' own structural tail the same way
   #partners overlaps the road's, so founders starts arriving as the
   marquee leaves instead of after a ~85%-black gap. --about-enter (the
   pin's own fade-in, separate from its content's faster --el-o fade) keeps
   the pin from popping in already-opaque and hard-cutting over partner. */
/* Round 11 (A2 item 8): grown from 200vh (100vh pin + a 100vh distance) to
   fit a real ~70vh hold once everything has entered, on top of the existing
   staggered entrance and exit -- see the real-vh timeline in updateAbout()
   (main.js) for the exact breakdown (entrance ends ~59vh in, hold to
   ~129vh, exit finishes ~189vh, +21vh buffer = 210vh distance). */
.about { position: relative; z-index: 3; height: 310vh; margin-top: -100vh; }
.about-pin {
  position: sticky; top: 0; height: 100vh; min-height: 38rem; overflow: hidden;
  display: flex; align-items: center; background: var(--black);
  opacity: min(var(--about-enter, 1), calc(1 - var(--about-tail, 0)));
}
.about-grid {
  position: relative; z-index: 1; width: 100%;
  display: grid; grid-template-columns: 1.05fr 0.95fr; gap: clamp(3rem, 8vw, 7rem); align-items: center;
}
/* B2: one real photo (Blue's own founders-image), framed like a print. B3:
   photo, heading, text and meta/button each own --el-o / --el-x (set in
   updateAbout) so they swipe left and fade one at a time on exit. */
.founders-photo, .founders-heading, .founders-text, .founders-meta {
  opacity: var(--el-o, 0);
  transform: translateX(var(--el-x, 1.5rem));
}
/* Reviewer round 3: photo-josh-angel.jpg is real and 1600x1067 (not the
   fabricated one, not a low-res crop), so it can run large like Blue's
   founders-image. aspect-ratio matches the file exactly (no forced 16:10),
   so object-fit:cover never actually crops either person out. */
.founders-photo {
  width: min(100%, 42vw); max-width: 34rem; aspect-ratio: 1600 / 1067; overflow: hidden;
  border-radius: 1rem; border: 1px solid var(--rule); box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5);
}
.founders-photo img { width: 100%; height: 100%; object-fit: cover; display: block; filter: saturate(0.94); transition: transform 900ms ease, filter 400ms ease; }
.founders-photo:hover img { transform: scale(1.03); filter: saturate(1.05); }
.about-copy { display: grid; gap: 1.25rem; }
.about-copy .eyebrow { margin-bottom: 1rem; }
.about-subline { font-family: "Clash Display", Arial, sans-serif; font-size: clamp(1.1rem, 1.8vw, 1.4rem); font-weight: 600; }
.founders-text p:not(.about-subline) { max-width: 35rem; margin-top: 0.75rem; color: var(--muted); font-size: 1.05rem; }
.founders-names { color: var(--meta); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.founders-meta .button { margin-top: 0.85rem; }

/* ---------- Results + Finish, one pinned stage: the finish frame fades up
   from black, the results block pops in over it then pops out, then she
   runs the stretch and crosses the line, and the closing call arrives.
   See updateFinish in main.js for the progress bands. ---------- */
/* Reviewer round 2 (P2/P3): overlaps founders' own structural tail with a
   negative margin, sitting behind it (lower z-index) so about's pin fading
   its opacity away crossfades onto the finish frame, which is already
   fading up via its own canvasIn well before the overlap ends. The bottom
   mask (P3) softens the sticky stage's own hard edge into #contact below,
   which shares its exact background color. */
/* 150vh, not 100vh: with the pin's own opacity now also tied to the exit
   fade (see updateAbout), finish needs to already be spatially present and
   fading its own canvas in before founders' content finishes leaving, or
   there is a window where content is gone but finish has nothing to show. */
/* 150vh: wider than #about's own 100vh structural tail, so finishFilm is
   already spatially present and fading its canvas in before founders'
   content finishes leaving (with the strict z-index order above, this no
   longer risks painting over #partners -- see the note there). */
/* Round 13 (Dylan direct): +70vh so the closing call ("Finish your next race
   with RUNROO", buttons, the whole FINISH LINE image) holds on screen for
   about a swipe before the page moves on -- it used to scroll off ~14vh after
   it settled. FT in main.js was rescaled so every earlier beat keeps its
   same absolute vh. */
/* Round 14 (item 5): 580vh (was 530vh): the results hold gained 50vh, see FT in main.js. */
.finish-film { position: relative; z-index: 1; height: 580vh; margin-top: -150vh; background: var(--black); }
.finish-stage {
  position: sticky; top: 0; height: 100vh; overflow: hidden; background: var(--black);
  mask-image: linear-gradient(to bottom, #000 82%, transparent);
  -webkit-mask-image: linear-gradient(to bottom, #000 82%, transparent);
}
/* Round 11 (A2 item 9): gentle zoom while it fades in, scale 1.08 -> 1,
   tied to the same canvasAlpha that already drives the fade -- no separate
   timing needed, it settles the instant the fade finishes. */
.finish-canvas {
  position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; transition: opacity 100ms linear;
  transform: scale(var(--finish-canvas-zoom, 1.08));
}
/* B3: the old gradient jumped from transparent to 78% opacity across just
   25% of the viewport height (20%-45%), which read as a visible straight
   edge sliding up the screen as --results-in changed. This one ramps
   smoothly across the full height, no hard stop. The text itself also gets
   its own soft, content-shaped backdrop (see .results-inner) so legibility
   does not depend on a viewport-wide overlay at all. */
/* Round 11 (A2 item 10): this was the "moving black shadow/band with a
   hard horizontal edge" -- a full-viewport 4-stop gradient whose OVERALL
   opacity rose with --results-in. All four stops scaled together (so the
   gradient's own shape never changed), but because the stops themselves
   are uneven (0.08 at the top, 0.58 at the bottom), rising overall opacity
   widened the visible top/bottom contrast as you scrolled, reading as a
   band whose edge crept up the frame -- confirmed live by sampling
   .finish-grade's own opacity at three scroll positions (0.02, 0.77, 0.8)
   over a fixed background. Hidden (not removed -- keeps the desk-lock
   snapshot's element occurrence count stable) rather than replaced with
   another scrim: the result block now relies on .results-panel's own
   text-shadow for legibility, the same approach already used elsewhere on
   this page once a similar boxed/scrim background was removed. */
.finish-grade { display: none; }
/* Item 10: "the results block simply fades in" -- opacity only, no rise.
   Each piece inside (title lines, stats, button, chart) gets its own
   entrance below instead of the whole block moving as one unit. */
.results-overlay { position: absolute; inset: 0; z-index: 2; display: flex; align-items: center; padding-top: var(--nav-h); opacity: var(--results-in, 0); }
.results-overlay[aria-hidden="true"] { pointer-events: none; }
.results-inner {
  position: relative; z-index: 1; display: grid; grid-template-columns: 1fr 1.1fr; gap: clamp(2rem, 5vw, 5rem); align-items: center; width: 100%;
  padding: 2.5rem clamp(1.5rem, 4vw, 3rem);
}
.results-panel { display: flex; flex-direction: column; align-items: flex-start; text-shadow: 0 2px 20px rgba(0, 0, 0, 0.7); }
.results-panel h2 { font-size: clamp(2.4rem, 4.6vw, 4.5rem); }
.results-panel h2 span { color: var(--gold); }
/* Round 11 (A2 item 10): the title's two lines, the three stats and the
   button each get their own staggered entrance now (was: title as one
   piece, then stats separately staggered, then button with the title) --
   eyebrow -> line 1 -> line 2 -> stat 1/2/3 -> button, all reusing the
   same continuously-updating --results-in signal with increasing
   transition-delay, the technique already used across this page. */
@media (min-width: 37.5625rem) {
html:not(.is-calm) .results-panel .eyebrow {
  opacity: var(--results-in, 0); transform: translateY(calc((1 - var(--results-in, 0)) * 1rem));
  transition: opacity 500ms ease, transform 550ms ease;
}
html:not(.is-calm) .rt-line {
  display: inline-block; opacity: var(--results-in, 0); transform: translateY(calc((1 - var(--results-in, 0)) * 1rem));
  transition: opacity 500ms ease, transform 550ms ease;
}
html:not(.is-calm) #resultsTitle .rt-line:nth-of-type(1) { transition-delay: 120ms; }
html:not(.is-calm) #resultsTitle .rt-line:nth-of-type(2) { transition-delay: 240ms; }
html:not(.is-calm) .stat {
  opacity: var(--results-in, 0); transform: translateY(calc((1 - var(--results-in, 0)) * 1rem));
  transition: opacity 500ms ease, transform 550ms ease;
}
html:not(.is-calm) .stat:nth-child(1) { transition-delay: 380ms; }
html:not(.is-calm) .stat:nth-child(2) { transition-delay: 480ms; }
html:not(.is-calm) .stat:nth-child(3) { transition-delay: 580ms; }
html:not(.is-calm) .results-panel > a.button {
  opacity: var(--results-in, 0); transform: translateY(calc((1 - var(--results-in, 0)) * 1rem));
  transition: opacity 500ms ease 720ms, transform 550ms ease 720ms;
}
/* Item 10: the chart swipes in from the right instead of just appearing
   with the rest of the block. */
html:not(.is-calm) .results-side {
  opacity: var(--results-in, 0); transform: translateX(calc((1 - var(--results-in, 0)) * 4rem));
  transition: opacity 600ms ease 200ms, transform 700ms cubic-bezier(.2, .7, .2, 1) 200ms;
}
}
/* Calm-mode-only decorative band; see .is-calm .finish-photo-band below. */
.finish-photo-band { display: none; }
.finish-cta {
  position: absolute; z-index: 3; left: 50%; top: 40%; width: min(60rem, 90vw);
  transform: translate(-50%, calc(-50% + ((1 - var(--finish-cta, 0)) * 2rem)));
  text-align: center; opacity: var(--finish-cta, 0); pointer-events: none;
  text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5);
}
.finish-film.is-called .finish-cta { pointer-events: auto; }
/* The tint arrives with the closing words and is gone before them. */
.finish-stage::after { content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; background: radial-gradient(ellipse at 50% 42%, rgba(0, 0, 0, 0.62), rgba(0, 0, 0, 0.18) 70%); opacity: var(--finish-cta, 0); }
.finish-cta h2 { font-size: clamp(2.2rem, 5vw, 4.75rem); }
.finish-cta h2 span { color: var(--gold); display: inline-flex; align-items: center; gap: 0.15em; }
/* B4: the gold kangaroo sits right after "RUNROO", sized to the line height. */
.finish-cta-icon { width: 0.8em; height: 0.8em; }
/* Round 7 (A2): phone-only pieces, hidden everywhere else so desktop is
   byte-identical in behavior (the phone block flips them on). */
.finish-cta-badge, .blog-nav { display: none; }
/* Round 9 (A2 items 8/10/11): shared "keep scrolling" cue, phone-only
   (hidden here, shown inside the phone block). */
.scroll-cue { display: none; }
.finish-cta .button-row { justify-content: center; }
/* B4: the primary button presses/pops into place as the call finishes arriving. */
#finishCtaBtn { transform: scale(var(--btn-pop, 0.85)); transition: transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1); }
#finishCtaBtn:hover { transform: scale(var(--btn-pop, 0.85)) translateY(-2px); }

/* ---------- Race notes: Website Blue's blog. ---------- */
.blog { padding: 8rem 0 6rem; background: var(--black); }
.blog-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1.5rem; margin-bottom: 2.5rem; }
.blog-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
.blog-card {
  display: flex; flex-direction: column; overflow: hidden;
  border: 1px solid var(--rule); border-radius: 0.75rem; background: var(--panel);
  transition: border-color 300ms ease, box-shadow 300ms ease;
}
.blog-card:hover { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) inset, 0 20px 50px rgba(0, 0, 0, 0.45), 0 0 30px rgba(212, 167, 44, 0.14); }
.blog-card-img { position: relative; height: 12.5rem; background-size: cover; background-position: center; transition: transform 800ms ease; }
.blog-card-img::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.55)); }
.blog-card:hover .blog-card-img { transform: scale(1.04); }
.blog-card-body { display: flex; flex: 1; flex-direction: column; padding: 1.4rem 1.5rem 1.5rem; }
.blog-card-meta { display: flex; align-items: center; margin-bottom: 0.75rem; color: var(--gold); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.blog-card-meta .dot { margin: 0 0.6rem; }
.blog-card-meta span:last-child { color: var(--muted); }
.blog-card h3 { font-size: 1.25rem; line-height: 1.15; text-transform: none; letter-spacing: 0; transition: color 200ms ease; }
.blog-card-excerpt { flex: 1; margin: 0.6rem 0 1.1rem; color: var(--muted); font-size: 0.9rem; line-height: 1.5; }
.blog-card-link { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--muted); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.blog-card:hover h3, .blog-card:hover .blog-card-link { color: var(--gold); }
.blog-card:hover .blog-card-link svg { transform: translateX(4px); }
.blog-foot { display: flex; justify-content: center; margin-top: 3rem; }

/* ---------- Newsletter: Website Blue. ---------- */
.newsletter { padding: 5rem 0; border-top: 1px solid var(--rule); background: var(--black); }
.newsletter-band { display: grid; grid-template-columns: 1fr 1.25fr; gap: clamp(2rem, 5vw, 4rem); align-items: center; }
.newsletter-copy h2 { font-size: clamp(1.8rem, 3vw, 2.75rem); }
.newsletter-copy p:not(.eyebrow) { max-width: 30rem; margin-top: 1rem; color: var(--muted); }
.newsletter-form { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: end; }
.newsletter-form .button { width: 100%; }

.results-panel .eyebrow { margin-bottom: 1rem; }
.results-panel > p:not(.eyebrow, .demo-note) { margin-top: 1.5rem; color: var(--muted); }
.stat-row { display: flex; gap: 2.5rem; margin: 2rem 0 1.75rem; }
.stat { display: grid; gap: 0.25rem; padding-left: 0.75rem; border-left: 2px solid var(--gold); }
.stat strong { font-family: "Clash Display", Arial, sans-serif; font-size: clamp(2rem, 3.2vw, 3rem); font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.stat span { color: var(--muted); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.results-table { width: 100%; margin-bottom: 1rem; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 0.75rem; background: var(--deep); }
.results-row { display: grid; grid-template-columns: 0.6fr 0.7fr 2fr 1.2fr 0.9fr; gap: 0.5rem; padding: 0.8rem 1rem; border-bottom: 1px solid rgba(255, 255, 255, 0.08); font-size: 0.9rem; font-variant-numeric: tabular-nums; transition: background 160ms ease; }
.results-row:last-child { border-bottom: 0; }
.results-row:not(.results-head):hover { background: rgba(255, 255, 255, 0.05); }
.results-head { background: var(--raised); color: var(--muted); font-family: "Space Mono", monospace; font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; }
.results-row.is-first { border-left: 2px solid var(--gold); }
.results-row.is-first span:nth-child(4) { color: var(--gold); font-weight: 700; }
.demo-note { margin: 0.75rem 0 5.5rem; color: var(--meta); font-size: 0.75rem; }

/* Community: the crew photo stays mostly visible. A compact corner block
   (heading + one line + button) sits bottom-left; four hover/focus/tap
   chips (title + icon always visible, description expands) run along the
   bottom, covering little of the people. */
/* Round 4 (item 2): pinned for a hold (see HOLD_VH in updateCommunity, main.js),
   then a zoom+fade-to-black exit (EXIT_VH) before the sticky pin releases and
   #blog -- already on the same solid black background -- slides up out of it
   with no crossfade trickery needed. 230vh = 100vh pin + 70vh hold + 60vh exit. */
.community { position: relative; height: 230vh; background: var(--black); }
.community-pin { position: sticky; top: 0; height: 100vh; overflow: hidden; }
.community-video, .community-scrim, .community-fade { position: absolute; inset: 0; width: 100%; height: 100%; }
/* P4: the photo rises out of black instead of starting with a hard top edge. */
.community-video {
  object-fit: cover;
  mask-image: linear-gradient(to bottom, transparent 0%, #000 20%);
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 20%);
  transform: scale(var(--community-zoom, 1));
}
.community-fade { z-index: 3; background: var(--black); opacity: var(--community-fade-o, 0); pointer-events: none; }
/* B5: copy moved to the right, leaving the left of the photo (the crew's
   faces) open. Scrim darkens from the right and bottom instead of a flat
   bottom band. */
.community-scrim {
  background:
    linear-gradient(to left, rgba(0, 0, 0, 0.82) 0%, rgba(0, 0, 0, 0.38) 45%, transparent 72%),
    linear-gradient(0deg, rgba(0, 0, 0, 0.78) 0%, transparent 38%);
}
/* Phase 2 verification: overlap.mjs (1440x900) caught "Join the community"
   sitting under the fixed chat bubble (spec 18c: no pinned stage may place
   text/buttons in the bottom-right 84x84 zone). Bottom offset raised so the
   button clears the bubble (62px + 24px offset = 86px from the viewport
   bottom) with real margin. */
/* Round 8 fix: raising bottom alone wasn't enough -- this block also has
   .reveal's own IntersectionObserver entrance transform (translateY(2rem)
   -> none), so while it's settling into place there's a scroll/time window
   where its rect sweeps through a taller vertical range than its final
   resting position alone suggests (measured live: ~105px of vertical drift
   between two 100px-apart scroll samples during that window). Raised well
   past the chat bubble's zone with real margin so the whole sweep, not just
   the settled end state, clears the bottom-right 100x100px the bubble owns. */
.community-corner {
  position: absolute; z-index: 2; right: var(--page-pad); bottom: clamp(16rem, 34vh, 20rem); width: min(34rem, calc(100% - (2 * var(--page-pad)))); text-align: right;
  text-shadow: 0 2px 20px rgba(0, 0, 0, 0.6);
}
/* Round 12 (A2 item 18): "Free for every runner" small plain text in the
   photo's bottom-right corner -- phone only (see the max-width:600px block
   for its visible styling). display:none is the base/desktop/tablet/calm
   state so this new element renders as nothing at every locked breakpoint;
   a fresh desk-lock diff against a plain <p> here (no CSS at all) caught it
   rendering as a full-width white paragraph on desktop before this rule. */
.community-freetag { display: none; }
.community-corner .eyebrow { margin-bottom: 0.85rem; }
/* Sized to hold "Run with" on one line and "the crew." on the next at the
   corner block's own width, not the full-bleed hero/section h2 scale. */
.community-corner h2 { font-size: clamp(2.2rem, 4.4vw, 4rem); }
.community-corner h2 span { color: var(--gold); }
/* Round 6 (A2 item 2): was `.community-corner > p:not(.eyebrow)` -- broke
   when the eyebrow/h2 and the p/button split into their own nested wrappers
   (.community-caption/.community-info) for phone's photo-overlay layout.
   Same descendant, one level deeper; unaffected on desktop since both
   wrappers still render as plain flow children of the same corner block. */
/* Round 8 (A2 item 6): was unconstrained inside the 34rem block, so at these
   widths the sentence ran close to full-width in one line, straight across
   the crew's heads. 34ch caps it to Dylan's requested width, wrapping to
   2-3 short lines instead -- margin-left:auto keeps it right-aligned (the
   block itself is text-align:right) inside the wider parent. */
.community-corner p:not(.eyebrow) { margin-top: 1rem; margin-left: auto; max-width: 34ch; color: var(--muted); font-size: 0.95rem; }
.community-corner .button { margin-top: 1.35rem; }
/* Round 13 (Dylan direct): desktop/tablet take the phone layout's lead --
   the whole caption sits up in the sky (top-right), over a soft dark corner
   fade instead of the old right-side scrim, so it never covers the runners.
   The eyebrow leaves the box and "Free for every runner" becomes small plain
   text at the photo's bottom-right, above the chip row and clear of the
   chat bubble. Film mode only (calm keeps its own locked layout). */
@media (min-width: 37.5625rem) {
  html:not(.is-calm) .community-scrim {
    background: radial-gradient(ellipse 72% 78% at 100% 0%, rgba(0, 0, 0, 0.76), rgba(0, 0, 0, 0.42) 55%, transparent 82%),
                linear-gradient(0deg, rgba(0, 0, 0, 0.78) 0%, transparent 30%);
  }
  html:not(.is-calm) .community-corner {
    top: calc(var(--nav-h) + 1.75rem); bottom: auto; width: min(38rem, calc(100% - (2 * var(--page-pad))));
  }
  html:not(.is-calm) .community-corner .eyebrow { display: none; }
  html:not(.is-calm) .community-corner h2 { font-size: clamp(2.6rem, 5.4vw, 5rem); line-height: 0.95; }
  html:not(.is-calm) .community-corner p:not(.eyebrow) { max-width: 32ch; margin-top: 0.85rem; color: rgba(255, 255, 255, 0.9); }
  html:not(.is-calm) .community-corner .button { margin-top: 1.1rem; }
  html:not(.is-calm) .community-freetag {
    display: block; position: absolute; z-index: 2; right: calc(var(--page-pad) + 5.5rem); bottom: calc(1.75rem + 5.5rem);
    margin: 0; font-size: 1rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; /* Round 14 (item 6): was 0.72rem */
    color: rgba(255, 255, 255, 0.85); text-shadow: 0 1px 8px rgba(0, 0, 0, 0.8);
  }
}
/* B4: no align-items meant the default grid stretch made every chip's own
   box grow to match whichever one was hovered/expanded (its content stayed
   empty, but the border/background box still grew) -- looked like hovering
   one expanded all four. align-items:end sizes each chip to its own content
   and keeps every chip-head anchored to the same baseline. */
.community-chips {
  position: absolute; z-index: 2; left: var(--page-pad); right: var(--page-pad); bottom: 1.75rem;
  display: grid; grid-template-columns: repeat(4, minmax(0, 14rem)); justify-content: center; align-items: end; gap: 0.85rem;
}
.chip {
  padding: 1rem 1.1rem; border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 0.85rem;
  background: rgba(10, 10, 10, 0.68); backdrop-filter: blur(10px);
  transition: border-color 200ms ease, background 200ms ease;
}
.chip:hover, .chip:focus-within, .chip.is-open { border-color: var(--gold); background: rgba(10, 10, 10, 0.9); }
.chip-head { display: flex; align-items: center; gap: 0.6rem; }
.chip-head svg { flex: none; width: 1.15rem; height: 1.15rem; fill: none; stroke: var(--gold); stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.chip-head h3 { font-size: 0.85rem; line-height: 1.2; }
.chip-body { max-height: 0; overflow: hidden; opacity: 0; transition: max-height 350ms ease, opacity 250ms ease; }
.chip:hover .chip-body, .chip:focus-within .chip-body, .chip.is-open .chip-body { max-height: 9rem; opacity: 1; }
.chip-body p { margin-top: 0.6rem; color: var(--muted); font-size: 0.8rem; line-height: 1.45; }
.chip-link { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 0.6rem; color: var(--gold); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }

/* min-height was 125vh with far less content than that to fill it, which
   read as a big dead area below the form; padding sizes it to its content
   instead. Background matches --black exactly (not --deep) so there is no
   seam against the finish pin's pure-black stage above it. */
.contact { display: grid; grid-template-columns: 0.85fr 1.15fr; gap: clamp(3rem, 7vw, 8rem); align-items: center; padding: 7rem var(--page-pad); background: var(--black); }
.contact-intro .eyebrow { margin-bottom: 1rem; }
.contact-intro h2 { font-size: clamp(2rem, 3.6vw, 3.5rem); }
.contact-intro > p:not(.eyebrow) { max-width: 34rem; margin-top: 1.25rem; color: var(--muted); }
.contact-form { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: clamp(1.5rem, 4vw, 3rem); border: 1px solid var(--rule); border-radius: 1rem; background: var(--navy); }
.field { display: grid; gap: 0.4rem; }
.field-wide { grid-column: 1 / -1; }
.field label { font-size: 0.78rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
.field input, .field textarea { width: 100%; min-height: 3rem; padding: 0.75rem; border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 0.5rem; background: var(--deep); color: var(--white); resize: vertical; }
.field input:focus, .field textarea:focus { border-color: var(--white); outline: 2px solid var(--white); outline-offset: 2px; }
.form-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.5rem; }
.form-status { min-height: 1.5rem; color: var(--muted); font-size: 0.8rem; }
.field select { width: 100%; min-height: 3rem; padding: 0.75rem; border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 0.5rem; background: var(--deep); color: var(--white); }
.field select:focus { border-color: var(--white); outline: 2px solid var(--white); outline-offset: 2px; }

.js .reveal { opacity: 0; transform: translateY(2rem); transition: opacity 650ms ease, transform 650ms ease; }
.js .reveal-zoom { transform: scale(0.9); transition: opacity 800ms ease, transform 900ms cubic-bezier(0.2, 0.7, 0.2, 1); }
.js .reveal-left { transform: translateX(-5rem); transition: opacity 800ms ease, transform 900ms cubic-bezier(0.2, 0.7, 0.2, 1); }
.js .reveal-right { transform: translateX(5rem); transition: opacity 800ms ease, transform 900ms cubic-bezier(0.2, 0.7, 0.2, 1); }
/* The community photo arrives with a soft zoom-out instead of a hard cut. */
.js .reveal-photo { transform: scale(1.08); transition: opacity 900ms ease, transform 1100ms cubic-bezier(0.2, 0.7, 0.2, 1); }
.js .reveal { transition-delay: var(--d, 0ms); }
.js .reveal.is-visible { opacity: 1; transform: none; }
/* B2: founders' two heading lines are inline spans, each with its own
   one-shot entrance (line 1 left, line 2 right + a slight scale). */
.founders-heading h2 { display: block; }
.js .h-line1, .js .h-line2 { display: inline-block; }
.js .h-line2.reveal-right { transform: translateX(5rem) scale(0.88); }
/* Round 3 review fix: both lines are <span>s (needed so each can carry its
   own reveal animation), so the shared ".section h2 span{color:gold}" rule
   (line 299) was painting BOTH gold. Only "Strother." should be gold, same
   as every other homepage heading's highlighted word -- "Josh & Angel"
   stays white by overriding it back on the more specific class selector. */
.founders-heading h2 .h-line1 { color: var(--white, #fff); }

/* ---------- Tablet ---------- */
@media (max-width: 68rem) {
  .steps-route { grid-template-columns: repeat(2, 1fr); }
  .steps-route::before { display: none; }
  /* Round 8, item 1: was flex-direction:column down through 1088px, forcing
     3 tall (13rem-photo) cards to stack inside the 100vh pinned stage's
     overflow:hidden -- the combined height blew past the viewport and
     clipped each card's own writing (num/title/desc/button), reported at
     ~605px wide ("FIND YOUR NEXT RACE" cut off mid-word). Dylan's ask: keep
     a single ROW down to ~900px (base .hero-card's own min(24rem,27vw)
     already narrows correctly with viewport width -- no override needed
     here), only switch to a stack below that, in the new 56.25rem query
     below, WITH a compact photo so the stack still fits. */
  .hero-lines { left: 5vw; }
  .hero-line { font-size: clamp(1.6rem, 4.6vw, 2.4rem); }
  /* Round 8 (A2 item 3): this used to collapse the whole grid to one column
     (title+photo+bullets all stacked), which is the phone treatment
     bleeding into tablet widths Dylan flagged -- the title-above/
     photo-beside-bullets area grid (base rule, above) now holds down to
     601px on its own; only the column ratio and padding tighten here. */
  .partner-grid { column-gap: 1.5rem; padding-top: calc(var(--nav-h) + 1rem); }
  .partner-image { width: min(100%, 24rem); }
  /* Only the headline and CTA fit a pinned 100vh stage once results stack to one column. */
  .results-side { display: none; }
  /* Round 8 (A2 item 4): .about-grid dropped from this collapse -- founders
     is two columns (photo + text) at every width down to 601px now; only
     results/contact/newsletter still go single-column here. */
  .results-inner, .contact, .newsletter-band { grid-template-columns: 1fr; }
  /* Round 8 (A2 item 6): was repeat(2, minmax(0,14rem)) -- a fixed 14rem
     floor forced 2x2 at these widths since 4x14rem never fits. Flexible
     1fr columns keep all four chips in ONE row (Dylan's ask) down to
     768px; the right clearance (unchanged) still keeps the row clear of
     the fixed chat bubble's bottom-right corner. */
  .community-chips { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.6rem; right: calc(var(--page-pad) + 4.5rem); }
  .chip-head h3 { font-size: 0.78rem; }
  .blog-grid { grid-template-columns: 1fr; }
  .blog-card { flex-direction: row; }
  .blog-card-img { flex: 0 0 38%; height: auto; min-height: 12rem; }
  .race-tile { width: min(19rem, 56vw); }
  .services-pin { gap: 2rem; }
}

/* Round 8, item 1: below ~900px a single row no longer has room, so the
   cards stack -- but with a compact photo band (~30% of a typical card's
   own height, per Dylan's ask) instead of the old fixed 13rem, so 3 stacked
   cards' num/title/desc/button all still fit inside the 100vh pinned
   stage's overflow:hidden between 601px and 900px (checked 768x1024,
   900x800; the phone media query below 600px overrides this again with its
   own full-screen big-card layout, untouched). */
@media (max-width: 56.25rem) {
  /* Stacked, so top/bottom padding matters now (the row layout above never
     needed it) -- reserve header + race-line-bar clearance so the top card
     doesn't sit under the fixed header and the bottom card doesn't touch the
     fixed start/finish bar (measured: both edges landed flush at 0/800px at
     900x800 with no padding). */
  .hero-cards { flex-direction: column; gap: 0.6rem; padding: calc(var(--nav-h) + 1rem) var(--page-pad) 3.5rem; }
  /* Dylan direct: text locked, so no more font-size overrides here (removed
     num/title/desc shrinking) -- stacked cards instead share the stack's
     own definite height equally (flex:1 1 0), same lever as the 600px
     block below, so the photo is what shrinks as the viewport gets tighter. */
  .hero-card { width: min(26rem, 82vw); flex: 1 1 auto; min-height: 0; height: auto; }
  .hero-card-body { padding: 0.9rem 1.2rem 1.1rem; gap: 0.4rem; }
}

/* ---------- Phone ---------- */
@media (max-width: 44rem) {
  :root { --nav-h: 4.25rem; }
  h2 { font-size: clamp(2rem, 9.4vw, 2.75rem); }
  .hero { height: 650vh; } /* Angel 9/29: ~30% less scroll (was 924vh). Round 12: 824/1099 keeps the same 0.75 distance ratio to desktop as before, so the JS fractions land at the same relative beats */
  .hero-cue { bottom: 3.4rem; font-size: 0.6rem; letter-spacing: 0.14em; }
  .hero-stage { min-height: 34rem; }
  .hero-scrim { box-shadow: inset 0 -18rem 12rem rgba(0, 0, 0, 0.95); }
  .hero-content { bottom: 7rem; }
  /* top:0/bottom:0 (was top:auto/bottom:1rem) restores a DEFINITE container
     height -- with top:auto the box sized to its own content (indefinite
     main size), which silently breaks flex:1 1 0's equal-share distribution
     on the cards below (each collapsed to ~2px, letting their locked-size
     text visually escape via overflow:visible -- found via the Dylan-direct
     card-text-lock verification script at 605x900, which sits in this old
     block's 704px range). */
  .hero-cards { flex-direction: column; top: 0; bottom: 0; transform: none; gap: 0.5rem; }
  /* Dylan direct: no more fixed photo height here either -- flex:1 1 0
     shares the stack's own height equally, same as the 56.25rem block. */
  .hero-card { width: 100%; flex: 1 1 auto; min-height: 0; height: auto; }
  .hero-card-body { padding: 0.85rem 1rem 1rem; }
  .hero-lines { left: 5vw; gap: 1rem; }
  .hero-line { font-size: clamp(1.4rem, 6.8vw, 2rem); }
  /* The progress bar has nowhere to sit on a phone without landing on copy. */
  .race-line { display: none; }
  .blog { padding: 4.5rem 0; }
  .races-head { top: calc(var(--nav-h) + 1.5rem); }
  .races-track { top: 58%; gap: 0.85rem; }
  .race-tile { width: 78vw; min-height: 15rem; padding: 1.4rem; }
  .services-pin { gap: 1.5rem; padding: 0 5vw; }
  .steps-head { margin-bottom: 0; }
  .steps-route { grid-template-columns: repeat(2, 1fr); gap: 0.6rem; }
  .step-card { min-height: 0; padding: 1.1rem; }
  .step-num { width: 2.5rem; height: 2.5rem; margin-bottom: 1rem; font-size: 0.9rem; }
  .step-card h3 { font-size: 1.25rem; }
  .step-card p { display: none; }
  /* T8/T9: one box at a time on phone -- all four stack in the same slot
     (updateRoad switches to a sequential, non-overlapping reveal below
     704px, same fix Round 5 made for the old alternating-side stops).
     .is-left/.is-right's own left/right (Round 11) must be reset here too,
     or the more specific class selector wins over this inset:0 and the box
     goes back to being pinned to one side instead of filling the slot. */
  .road-grid { position: absolute; inset: calc(var(--nav-h) + 7rem) var(--page-pad) 1.5rem; display: block; }
  .road-box, .road-box.is-left, .road-box.is-right {
    position: absolute; inset: 0; width: 100%; left: 0; right: 0; top: 0;
    transform: none; --box-side: 0;
    /* Phone JS never sets --box-opacity (only the desktop drive-in branch
       does, see updateRoad) -- back to the plain --box-in crossfade here,
       unchanged from before Round 13. */
    opacity: var(--box-in, 0);
  }
  .road-stop-body { padding: 1.4rem 1.5rem; }
  /* Base .road-head is max-width:42vw for the desktop sidebar layout; on
     phone that's ~160px and wraps the two <br>-separated lines into 4. Widen
     to the full content column and drop the size so it holds at 2 lines. */
  .road-head { max-width: none; width: min(100%, 26rem); }
  .road-head h2 { white-space: normal; font-size: clamp(1.5rem, 6.8vw, 2.1rem); }
  .partner { height: 260vh; }
  .partner-grid { grid-template-columns: 1fr; }
  .partner-image { display: none; }
  .marquee-item { font-size: 1rem; }
  /* Round 5: phone keeps the OLD simple static-flow marquee -- the new
     rise/hold/fade schedule in updateFoot() is tuned in real vh against a
     much longer desktop pin distance; phone's #partner height is unchanged
     here (phase 2 redesigns phone), so forcing static+opacity:1 just shows
     it in normal flow under the text column instead of letting the new
     absolute-positioned/scroll-timed version run cramped and clipped. */
  .service-marquee { position: static; margin-top: 1.5rem; opacity: 1; transform: none; }
  .about { height: 170vh; }
  .about-grid { gap: 1.5rem; }
  .founders-photo { width: 100%; max-width: 26rem; }
  .finish-film { height: 460vh; } /* Round 14: +50vh with the longer results hold */
  .results-overlay { padding-top: calc(var(--nav-h) + 1rem); }
  .results-side { display: none; }
  .results-inner { grid-template-columns: 1fr; }
  .stat-row { gap: 1.25rem; }
  /* The frame sits as a band in the middle; the call sits below it. */
  .finish-cta { top: auto; bottom: 6vh; transform: translate(-50%, calc((1 - var(--finish-cta, 0)) * 2rem)); }
  /* A pinned overlay would cover faces at this width, so the photo, corner
     block and chips stack in normal document flow instead. */
  .community { display: block; height: auto; }
  /* Round 4 (item 2): the lock+zoom+fade pin is desktop/tablet only -- on
     phone the pin would just clip this column layout, and updateCommunity()
     itself skips phone widths (see the `phone` guard in main.js) so nothing
     is driving --community-zoom/--community-fade-o here anyway. */
  .community-pin { position: static; height: auto; overflow: visible; display: flex; flex-direction: column; min-height: 0; }
  .community-video { position: static; width: 100%; height: 46vh; transform: none; }
  .community-scrim { display: none; }
  .community-fade { display: none; }
  .community-corner { position: static; width: 100%; padding: 1.75rem var(--page-pad) 0; text-shadow: none; }
  .community-chips { position: static; grid-template-columns: 1fr 1fr; gap: 0.6rem; padding: 1.25rem var(--page-pad) 2.5rem; }
  .blog-card { flex-direction: column; }
  .blog-card-img { flex: none; height: 11rem; min-height: 0; }
  .newsletter { padding: 4rem 0; }
  .newsletter-form, .contact-form { grid-template-columns: 1fr; }
  .contact { min-height: auto; padding-top: 4.5rem; padding-bottom: 4.5rem; }
  .field { grid-column: 1; }
}

/* ---------- Calm tier. Same layout, same photography, same type: the films
   drop to their poster frames and the scroll effects drop to simple fades. ---------- */
.is-calm { scroll-behavior: auto; }
.is-calm *, .is-calm *::before, .is-calm *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
.is-calm .hero { height: 100vh; }
.is-calm .hero-canvas,
.is-calm .hero-intro,
.is-calm .hero-middle,
.is-calm .hero-start,
.is-calm .hero-lines,
.is-calm .hero-cards,
.is-calm .hero-cue,
.is-calm .hero-exit,
.is-calm .hero-legs,
.is-calm .race-line,
.is-calm .partner-canvas,
.is-calm .finish-canvas { display: none; }
.is-calm .hero-opening { opacity: 1; transform: none; }
/* .hero-fallback normally matches the intro video's own first frame (a
   close-up leg at the start line, so there is no flash when the video takes
   over) -- correct for film mode, but in calm mode it never gets replaced by
   the video and just sits there as a blurry thigh close-up. Calm mode alone
   swaps it for a clear front-on start-line still. */
.is-calm .hero-fallback { background-image: url("frames/launch/001.webp"); background-position: center 38%; }
/* .races-bg itself (not just the video) must go: it is position:absolute;
   inset:0 and once .races-pin above goes position:static, it escapes to the
   next positioned ancestor and paints a full-bleed black block over calm
   content instead of staying scoped to the (now collapsed) pin. */
.is-calm .races-bg { display: none; }
/* T6/T7: races and services collapse to stacked, readable content: no pin,
   no horizontal track, no empty scroll, same pattern as the road below. */
/* margin-top:-100vh on the base rule is the film-mode hand-off overlap (it
   pulls #races up to paint over the hero stage as it pins). In calm mode
   there is no pin, no hero stage to cover, and nothing else changes that
   negative margin, so it silently pulled the entire calm hero section (100vh)
   out from under itself and hid it behind #races. Cancel it here. */
.is-calm .section-races { height: auto; margin-top: 0; }
.is-calm .races-pin {
  position: static; height: auto; min-height: 0; opacity: 1; transform: none;
  display: block; padding: 5rem var(--page-pad);
}
.is-calm .races-head { position: static; opacity: 1; transform: none; margin-bottom: 2rem; }
.is-calm .races-track {
  position: static; transform: none; flex-wrap: wrap; top: auto; left: auto;
}
.is-calm .races-view-all { position: static; display: block; width: fit-content; margin: 2rem auto 0; opacity: 1; }
.is-calm .races-hint { display: none; }
.is-calm .section-services { height: auto; margin-top: 0; }
.is-calm .services-pin {
  position: static; height: auto; min-height: 0; display: block; padding: 5rem var(--page-pad);
}
.is-calm .services-pin::before { display: none; }
.is-calm .steps-head { opacity: 1; transform: none; margin-bottom: 2.5rem; }
.is-calm .step-card { opacity: 1; transform: none; }
.is-calm .steps-route { margin-bottom: 0; }
.is-calm .service-marquee { margin-top: 3rem; }
/* The road collapses to stacked, readable cards: no pin, no empty scroll. */
/* The outer .section-road still carries its film-mode height:460vh; without
   this it left ~4600px of empty space below the collapsed static cards. */
/* margin-top:0: the film-mode -100vh overlap (Round 11, item 6) pulls road
   up over services' own pin in film mode, but in calm mode both are plain
   stacked static blocks -- left unreset this pulled road's calm content up
   to visually collide with services' own tail (same mistake already made,
   and fixed, for #races in an earlier round). */
.is-calm .section-road { height: auto; margin-top: 0; }
.is-calm .road-pin { position: static; height: auto; min-height: 0; padding: 5rem var(--page-pad); display: flex; flex-direction: column; gap: 2rem; align-items: flex-start; }
.is-calm .road-bg { position: absolute; inset: 0; transform: none; mask-image: none; -webkit-mask-image: none; }
.is-calm .road-lines { display: none; }
.is-calm .road-head { position: static; opacity: 1; max-width: 40rem; margin-bottom: 1rem; transform: none; }
/* Round 11 (item 7): the 2x2 grid is calm-mode only now (film mode reverted
   to one-at-a-time alternating drive-in, see the base .road-box rule).
   position:relative -- .road-stop-photo is absolutely positioned against
   its nearest positioned ancestor, so this can't be static or every box's
   photo escapes to .section-road and covers all of them at once. is-left/
   is-right's own left/right must be reset here too, same reason as calm
   mode's other stop-derived resets (Round 8 T9). */
.is-calm .road-grid { position: static; display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; width: 100%; }
.is-calm .road-box, .is-calm .road-box.is-left, .is-calm .road-box.is-right {
  position: relative; width: 100%; opacity: 1; transform: none; left: auto; right: auto; top: auto;
}
.is-calm .road-stop-body { min-height: 16rem; padding: 1.85rem; }
@media (max-width: 44rem) {
  .is-calm .road-grid { grid-template-columns: 1fr; }
}
.is-calm .partner-list li { opacity: 1; transform: none; }
/* Round 4 (calm item 1): a clean static block -- photo left, copy right,
   a still of the foot film (poster-foot.jpg) as a darkened background on
   the pin itself (the canvas is JS-driven, hidden in calm), marquees below
   with real spacing. Reviewer round 2's negative-margin overlaps (P1/P2)
   are film-mode only: margin-top resets here too. */
.is-calm .partner { height: auto; margin-top: 0; }
.is-calm .partner-pin {
  position: static; height: auto; min-height: 0; display: block;
  padding: 6rem var(--page-pad) 4rem; mask-image: none; -webkit-mask-image: none;
  background-image: linear-gradient(rgba(0, 0, 0, 0.74), rgba(0, 0, 0, 0.74)), url("assets/poster-foot.jpg");
  background-size: cover, cover; background-position: center, center; background-repeat: no-repeat, no-repeat;
}
.is-calm .partner-stage { display: none; }
.is-calm .partner-grid {
  position: static; grid-template-columns: 0.9fr 1.1fr; padding-top: 0; gap: clamp(2rem, 5vw, 4rem); align-items: center;
}
.is-calm .partner-image {
  position: static; opacity: 1; transform: none; justify-self: stretch; width: 100%; max-width: 28rem;
}
.is-calm .partner-copy { position: static; opacity: 1; transform: none; }
/* Round 4 (item 1) fix: B1 split the single .partner-copy fade into its own
   .partner-heading/.partner-details opacity: var(--partner-in/-details-in)
   rules on the CHILD elements. The old calm override above only forces
   .partner-copy itself back to opacity:1 -- its children still carry their
   own opacity: var(--partner-in, 0) declarations, and that var is never set
   in calm mode (updateFoot never runs), so it defaulted to 0 and the whole
   heading/bullets/buttons block was invisible. */
.is-calm .partner-heading, .is-calm .partner-details { opacity: 1; transform: none; }
/* Founders collapses to a static block: no pin, no sideways exit. */
.is-calm .about { height: auto; margin-top: 0; }
.is-calm .about-pin { position: static; height: auto; min-height: 0; padding: 6rem var(--page-pad); opacity: 1; }
.is-calm .about-grid { opacity: 1; transform: none; }
/* Round 4 (item 2), calm mode: back to the original static, unpinned photo
   section -- no lock, no zoom, no fade-to-black. */
.is-calm .community { height: auto; }
/* position:relative (not static): .community-video/.community-scrim are
   still position:absolute;inset:0 (base rule), so this pin needs to stay a
   real positioned container with a real height for them to size against --
   static handed their containing block up to .community itself, which is
   height:auto and collapses to 0 since its only content is absolutely
   positioned (out of flow), rendering the whole photo as a sliver. */
.is-calm .community-pin { position: relative; height: auto; min-height: 100vh; overflow: visible; }
.is-calm .community-video { transform: none; }
.is-calm .community-fade { display: none; }
/* updateAbout() never runs in calm mode, so --el-o/--el-x (B3's per-element
   swipe) are never set; without this they default to the film-mode-only
   fallback (opacity 0), making founders invisible. */
.is-calm .founders-photo, .is-calm .founders-heading, .is-calm .founders-text, .is-calm .founders-meta { opacity: 1; transform: none; }
/* Results + finish collapse to a static, readable stack: no pin, both blocks visible. */
.is-calm .finish-film { height: auto; margin-top: 0; }
/* Round 4 (calm item 2): the finish-line photo used to cover the whole
   stacked stage, so the closing call sat right on top of the painted
   FINISH LINE text and crowded the results block above it. Plain black
   here; the photo moves to its own .finish-photo-band between the two. */
.is-calm .finish-stage { position: static; height: auto; display: flex; flex-direction: column; gap: 3.5rem; padding: 6rem var(--page-pad) 8rem; background: var(--black); mask-image: none; -webkit-mask-image: none; }
.is-calm .finish-grade { display: none; }
.is-calm .results-overlay { position: static; opacity: 1; transform: none; padding-top: 0; }
.is-calm .finish-stage::after { display: none; }
/* Round 5: the band is much wider than 182.jpg's own 16:9, so cover scales
   to fill the width and crops top/bottom; centered vertically that crop
   window landed on blank asphalt, well above the painted FINISH LINE text
   which sits in the image's own bottom ~25%. Bottom-aligned keeps the text
   in the visible window at any container width. */
.is-calm .finish-photo-band {
  display: block; height: 34vh; min-height: 14rem; border-radius: 0.75rem;
  background: url("assets/seq2/finish/182.jpg") center bottom / cover no-repeat;
}
.is-calm .finish-cta { position: static; opacity: 1; pointer-events: auto; transform: none; width: 100%; text-shadow: none; }
.is-calm .community-video { mask-image: none; -webkit-mask-image: none; }
.is-calm /* A fade is still motion, just the honest kind. */
.is-calm .reveal { transform: none !important; transition: opacity 400ms ease !important; }
.is-calm .reveal.is-visible { opacity: 1; }

/* ---------- Scroll-linked scenes: a section eases up to size as it arrives,
   then pushes in and fades as it leaves. ---------- */
.scene { --enter: 1; --exit: 0; }
.js .scene > .section-shell {
  opacity: calc((0.25 + (var(--enter) * 0.75)) * (1 - var(--exit)));
  transform: scale(calc(0.94 + (var(--enter) * 0.06) + (var(--exit) * 0.1)));
  transform-origin: 50% 40%;
  will-change: transform, opacity;
}
/* From idea to official results: a deliberate, visibly-larger zoom + fade
   (F5), not the subtle default every other .scene section gets. */
.js .services.scene > .section-shell {
  opacity: var(--enter);
  transform: scale(calc(1.15 - (var(--enter) * 0.15)));
}
.js.is-calm .scene > .section-shell { opacity: 1; transform: none; }

/* Tap targets clear 44px on touch. */
@media (hover: none) {
  .text-link { display: inline-flex; align-items: center; min-height: 44px; }
}

/* v1.2 (2026-09-24): reverted per client — white headlines, one gold highlight
   word/phrase (the .section h2 span / .hero-content h2 span rules above
   already color the highlight gold; this block just no longer inverts it). */
.community-video { object-position: 50% 62%; }
@media (max-width: 44rem) {
  .community-scrim { background: linear-gradient(180deg, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.62) 55%, rgba(0, 0, 0, 0.4) 100%); }
}

/* ===== PHONE (<=600px): shared + partners onward =====
   Phase 2 mobile spec (dylan-r5 follow-up, mobile-spec.md). One block, at
   the end of the file, so desktop/laptop/tablet (>=601px) and calm mode are
   byte-for-byte unaffected -- nothing above this line changed for this
   phase. JS gate: PHONE = matchMedia('(max-width:600px)') in main.js;
   updateFoot/updateAbout/updateFinish (partners/founders/results+finish)
   skip entirely under it, same as they already skip in calm mode, so this
   CSS is the only thing driving those sections here -- no fighting with
   scroll-jacked opacity/transform custom properties tuned for desktop.
   Reuses the existing calm-mode static-stack pattern (`.is-calm .partner-*`,
   `.is-calm .finish-*`, etc.) as its starting point, since that pattern
   already solves "no JS driving these vars, still needs to render cleanly";
   this block re-sizes/re-orders it to the phone spec instead of writing a
   fourth mode from scratch. */
@media (max-width: 600px) {
  :root { --page-pad: 1.25rem; }

  /* ---- Global type/buttons/inputs (spec: gutters, vertical rhythm, type
     scale, tap targets) ---- */
  h1 { font-size: clamp(2.1rem, 9vw, 2.6rem); }
  h2 { font-size: clamp(1.7rem, 7.5vw, 2.1rem); }
  h3 { font-size: 1.15rem; }
  body { font-size: 16px; line-height: 1.55; }
  .eyebrow { font-size: 11px; letter-spacing: .16em; }
  .button { min-height: 48px; padding: .75rem 1.25rem; font-size: .8rem; }
  .button-row { gap: .75rem; }
  .field input, .field textarea, .field select { min-height: 48px; font-size: 16px; }
  .field label { margin-bottom: .1rem; }

  /* ---- 5. PARTNERS: real motion restored (round 6). A short sticky
     media+band stage (#partnerMediaBand/#partnerMediaSticky, new wrapper
     divs in index.html -- display:contents above 600px so they don't touch
     desktop's layout at all), driven by updatePartnerPhone() in main.js:
     the foot canvas scrubs in the media window (no text on it), the copy
     (heading+bullets+buttons) fades in once as the word band below it. The
     photo and marquee stay exactly where the desktop DOM already has them
     (siblings of the media band inside #partnerPin), now in normal flow
     after the sticky stage releases. */
  .partner { height: auto; margin-top: 0; }
  .partner-pin {
    /* Round 7 fix: the base (desktop) rule has `overflow: hidden` -- never
       reset here, so #partnerPin (now a normal-flow flex column, not the
       sticky element itself on phone) stayed a clipping ancestor of its own
       nested sticky child (#partnerMediaSticky). An ancestor with overflow
       other than visible sitting between a sticky element and the real
       document scroller breaks position:sticky's containing-block math --
       confirmed live: the sticky child's rect exactly matched "no stickiness
       at all" (scrolled 1:1 with the page instead of pinning), which is
       exactly what produced the vanishing media window Dylan flagged.
       overflow: visible restores the same relationship desktop pins rely on
       (only .section's own overflow-x:clip should be in that chain). */
    position: static; height: auto; min-height: 0; display: flex; flex-direction: column;
    overflow: visible; mask-image: none; -webkit-mask-image: none;
  }
  /* display:block is required here, not just position/height -- the
     unconditional base rule (line ~650) sets display:contents so these
     wrappers are invisible to layout on desktop; contents wins over any
     property that isn't `display` itself, so without this the whole band
     computed a zero-size box (caught live: mediaBandOffsetHeight was 0). */
  /* Round 9 (A2 item 9): grown from 180svh so the sticky pin stays engaged
     through the whole photo+marquee lock sequence (see the svh table on
     updatePartnerPhone in main.js, which runs out to t=250) with margin
     before it releases to founders. */
  .partner-media-band { display: block; position: relative; height: 360svh; }
  .partner-media-sticky {
    display: flex; flex-direction: column;
    position: sticky; top: 0; height: 100svh; overflow: hidden;
    /* Round 9 (A2 item 8): the media window used to start at the very top of
       the sticky viewport, under the header's own translucent overlay --
       now that the header is solid black (A1's fix), that read as the foot
       film starting BEHIND it. Real top padding pushes the whole stage down
       to start right under the header instead. */
    padding-top: var(--rr-nav-h, 66px);
  }
  .partner-stage {
    /* Was 55svh; the header offset above already ate into the sticky
       viewport, so growing this only 3svh (bigger media window, per Dylan)
       instead of more keeps the word band below from running out of room --
       measured live at 375x667 after this change, still fits both bullets
       with the extra spacing below. */
    position: relative; height: 55svh; opacity: 1; flex: none;
  }
  .partner-grade { opacity: .5; }
  .partner-stage::after {
    content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 40px;
    background: linear-gradient(to bottom, transparent, var(--black));
  }
  /* The word band: JS-reparented #partnerCopy lands here directly (see
     layoutPartnerForBreakpoint() in main.js), filling the rest of the
     sticky viewport below the media window. */
  .partner-media-sticky #partnerCopy {
    flex: 1; display: flex; flex-direction: column; justify-content: center;
    padding: 16px var(--page-pad); background: var(--black);
    transition: opacity 500ms ease;
  }
  /* Round 12 (A2 item 10): one continuous sequence now instead of two
     crossfading "scenes" -- the photo pops in IN FRONT of the foot (over
     .partner-stage's own top region, not a separate full-screen layer) once
     the toe lifts, square-ish and higher/closer to the header. z-index 3
     puts it above the foot canvas (.partner-stage, z-index unset/auto in
     normal stacking) since both now occupy the same physical top region of
     the sticky stage at once. --phone-photo-in is set in updatePartnerPhone().
     The marquee no longer reparents in here on phone at all (A1 moved it to
     its own new placement after road box 04, item 9) -- if that reparent
     code still runs it is now dead weight, not a visual conflict, since
     nothing here positions or shows #serviceMarquee any more. */
  .partner-media-sticky #partnerImage {
    position: absolute; top: 0; left: var(--page-pad); right: var(--page-pad); z-index: 3;
    height: 42svh; margin: 0; transform: none; transition: none;
    opacity: var(--phone-photo-in, 0); pointer-events: none;
    border-radius: 14px; overflow: hidden; box-shadow: 0 18px 40px rgba(0, 0, 0, .5);
  }
  .partner-media-sticky #partnerImage img { width: 100%; height: 100%; object-fit: cover; }
  /* Round 7 (A2 item 1): the band itself is just black now; its pieces come
     in staggered once the pin is ~20% through (updatePartnerPhone toggles
     .is-band-in, reversible on scroll-back): title from the left, bullets
     one by one from the right, button zooms. Transitions are time-based
     off that single class flip, so each beat is a real ~650ms move, not a
     few pixels of scroll. */
  .partner-media-sticky #partnerCopy .partner-heading,
  .partner-media-sticky #partnerCopy .partner-list li,
  .partner-media-sticky #partnerCopy .button-row {
    opacity: 0; transition: opacity 650ms ease, transform 750ms cubic-bezier(.2, .7, .2, 1);
  }
  .partner-media-sticky #partnerCopy .partner-heading { transform: translateX(-2.5rem); }
  .partner-media-sticky #partnerCopy .partner-list li { transform: translateX(1rem); }
  .partner-media-sticky #partnerCopy .partner-list li:nth-child(1) { transition-delay: 200ms; }
  .partner-media-sticky #partnerCopy .partner-list li:nth-child(2) { transition-delay: 340ms; }
  .partner-media-sticky #partnerCopy .partner-list li:nth-child(3) { transition-delay: 480ms; }
  .partner-media-sticky #partnerCopy .button-row { transform: scale(.8); transition-delay: 640ms; }
  .partner-media-sticky #partnerCopy.is-band-in .partner-heading,
  .partner-media-sticky #partnerCopy.is-band-in .partner-list li,
  .partner-media-sticky #partnerCopy.is-band-in .button-row { opacity: 1; transform: none; }
  /* Round 7 fix: heading + 3 bullets + 2 buttons measured 448-472px tall,
     taller than the ~300-380px actually available below a 55svh media
     window at 390x844 and 375x667 -- the second button was clipped off the
     bottom of the sticky viewport (overflow:hidden on the stage), not
     visible at all. Per Dylan: drop the second button here and tighten
     spacing rather than shrinking the media window. Calm mode restores it
     (see .is-calm override below) since that layout isn't height-boxed. */
  /* Round 6 (A2 item 1): Dylan wants the title centered with real air before
     the bullets (~24-28px) -- measured live at 375x667 the band has ~70px of
     slack even with the second button already dropped (round 7), so there's
     room without re-opening the height fight. Bullets stay left-aligned
     under the centered title per Dylan's own "can stay left-aligned" option;
     the button centers with them for a tidier, airier look. */
  /* Round 9 (A2 item 8): "Partner with us." on ONE line -- the <br> in the
     markup (kept for desktop's two-line stack) is hidden here, and the gold
     "Partner" span gets a right margin to replace the space the <br> used to
     provide, or the two words would run together as "Partnerwith us.". */
  .partner-media-sticky #partnerCopy .partner-heading br { display: none; }
  .partner-media-sticky #partnerCopy .partner-heading span { margin-right: .28em; }
  .partner-media-sticky #partnerCopy .partner-heading {
    margin-bottom: 8px; font-size: clamp(1.3rem, 6vw, 1.7rem); text-align: center; white-space: nowrap;
  }
  /* Smaller list type at 375px-wide keeps every bullet to one line instead
     of wrapping to two -- that alone was most of the missing height. Round 9
     (A2 item 8): more air between bullets (.3rem -> .55rem padding) and a
     real gap before the button (margin-top 10px -> 26px) -- the one-line
     title above frees up the vertical room this spends. */
  .partner-media-sticky #partnerCopy .partner-list li { padding: .42rem 0; font-size: .82rem; }
  .partner-media-sticky #partnerCopy .partner-list { margin: .35rem 0; }
  .partner-media-sticky #partnerCopy .button-row { margin-top: 16px; justify-content: flex-start; }
  .partner-media-sticky #partnerCopy .button-row a:last-child { display: none; }
  /* 2026-10-01 (Dylan: "Partner with us" read as disorganized on phone): heading, bullets and button
     were three different alignments (centered title, a centered block of left-aligned bullets, a
     centered button). Now one left edge, the same as the hero. The button also stays clear of the
     chat bubble column, and the "Keep scrolling" hint fades once the copy is up so it never sits
     against the button. */
  .partner-media-sticky #partnerCopy .partner-heading { text-align: left; }
  .partner-media-sticky #partnerCopy .partner-details { width: 100%; max-width: 21rem; margin-left: 0; margin-right: 0; }
  /* animation:none, not just opacity:0 -- the running scrollCuePulse animation overrides a declared opacity (see the finish cues below). */
  .partner-media-sticky:has(#partnerCopy.is-band-in) .scroll-cue { animation: none; opacity: 0; }
  .partner-media-sticky #partnerCopy .button-row a:first-child { min-height: 44px; padding: .6rem 1.1rem; }
  /* Round 6 fix: this used to be `padding: 24px 0 32px` -- a shorthand that
     zeroes left/right, wiping the horizontal gutter .section-shell had set
     (0 var(--page-pad)) instead of adding to it. Same bug hit .contact and
     .community-corner/-chips below; all fixed the same way, explicit
     var(--page-pad) on the sides instead of a shorthand that drops it. */
  .partner-grid { position: static; display: flex; flex-direction: column; gap: 16px; padding: 24px var(--page-pad) 32px; }
  /* Round 11 addendum: item 7 made #partnerCopy a real grid item again
     (grid-area: details; align-self: center) instead of display:contents.
     align-self also applies inside flexbox, so under this phone flex-column
     layout it shrank #partnerCopy to its content width and centered it
     (measured: left 0->22px, width 390->345px) instead of stretching full
     width like before. Reset align-self on phone so it stretches again. */
  .partner-copy { align-self: stretch; }
  /* Round 11 addendum, continued: the pre-round-11 baseline had
     .partner-details itself as the flex item with align-self:center (it was
     a direct grid-area under #partnerCopy's old display:contents), so it
     shrank to its own content width and centered (measured baseline: left
     42px, width 305px inside a 390px parent -- symmetric centering). Now
     that .partner-details is a plain block child of the stretched
     #partnerCopy instead of a flex item, replicate that same shrink+center
     behavion directly so phone pixels match the locked baseline exactly. */
  .partner-details { width: fit-content; max-width: 100%; margin-left: auto; margin-right: auto; }
  .partner-image {
    /* The older 44rem block (line ~1124) still has `display: none` for
       tablet/small-desktop, where the photo genuinely doesn't fit -- phone
       wants it back (spec item 2), so this needs its own display, not just
       the opacity/position/etc that block never touched. */
    display: block; position: static; opacity: 1; transform: none; width: 100%; max-width: none;
    aspect-ratio: 4 / 3; justify-self: stretch;
  }
  .partner-heading, .partner-details { opacity: 1; transform: none; }
  .partner-heading { font-size: clamp(1.7rem, 7.5vw, 2.1rem); }
  .js .partner-list li { opacity: 1; transform: none; }
  /* Round 11 addendum: the new desktop-only .button-row opacity rule
     (item 7, opacity: var(--partner-btn-in, 0)) has no phone equivalent that
     sets that variable, so on phone it defaulted to 0 -- the CTA buttons
     were permanently invisible. updateFoot() (the function that sets
     --partner-btn-in) already early-returns on phone, matching every other
     desktop-only entrance function here, so the fix is a reset, same pattern
     as the other phone resets right above. */
  .js:not(.is-calm) .partner-details .button-row { opacity: 1; transform: none; }
  .service-marquee { position: static; padding: 24px 0 40px; opacity: var(--marquee-alpha, 1); }
  .marquee-item { font-size: .95rem; }
  /* Round 9 (A2 items 8/10/11): shared "keep scrolling" cue -- a real
     element now that desk-lock.mjs keys by tag.firstClass + occurrence
     (patched by the coordinator) instead of a raw walk index, so a new
     `display:none`-at-desktop element no longer shifts every downstream
     key. Chevron is a rotated-border shape, not a glyph (house rule: no
     arrow characters in UI text). */
  .scroll-cue {
    display: flex; align-items: center; gap: 7px; position: absolute; left: var(--page-pad); bottom: 12px; z-index: 3;
    color: var(--gold); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .12em;
    text-shadow: 0 1px 6px rgba(0, 0, 0, .6); pointer-events: none; animation: scrollCuePulse 2s ease-in-out infinite;
  }
  .scroll-cue-chevron {
    width: 7px; height: 7px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
    transform: rotate(45deg); margin-top: -3px;
  }
  @keyframes scrollCuePulse { 0%, 100% { opacity: .6; transform: translateY(0); } 50% { opacity: 1; transform: translateY(3px); } }

  /* ---- 6. FOUNDERS: photo full gutter width (3:2), no pin. Staggered
     reveal handled by the existing --el-o/--el-x fallback below. ---- */
  .about { height: auto; margin-top: 0; }
  /* Round 7 (A2 item 1): 72px above and below (was 48px) so founders reads as
     its own section instead of crowding the marquee above / finish stage below. */
  .about-pin { position: static; height: auto; min-height: 0; padding: 72px 0 24px; opacity: 1; }
  .about-grid { display: flex; flex-direction: column; gap: 16px; opacity: 1; transform: none; }
  .founders-photo { order: 1; width: 100%; aspect-ratio: 3 / 2; }
  .founders-photo, .founders-heading, .founders-text, .founders-meta { opacity: 1; transform: none; }
  /* Round 7 (A2 item 1): founders type cleanup. Heading is a plain left-
     aligned two-line block -- the desktop per-line swipe (.reveal-left/-right
     spans) is neutralised here (the `.js .h-line2.reveal-right` rule kept a
     translateX(5rem) scale(.88) stuck on "Strother." after it revealed,
     which is the odd indent) and the whole heading animates in as one piece
     via the .ph-a2 helper below. Subline + body both 16px like every other
     phone section. */
  .founders-heading h2 { font-size: clamp(1.9rem, 8vw, 2.3rem); text-align: left; }
  .js .founders-heading h2 span.h-line1, .js .founders-heading h2 span.h-line2 {
    display: block; opacity: 1; transform: none; transition: none; margin: 0;
  }
  .founders-heading h2 br { display: none; }
  .about-subline { font-size: 1rem; }
  .founders-text p:not(.about-subline) { font-size: 1rem; }
  .js .about-subline.reveal { opacity: 1; transform: none; transition: none; }

  /* ---- 7. RESULTS + FINISH: round 7 choreography, one pinned ~500svh stage.
     Grid rows top to bottom: [closing head: badge + title] [finish-line
     media, 16:9, hard edges] [word band: results block, later the buttons].
     Beats (svh of scroll, see updateFinishPhone in main.js): the finish
     image fades in slowly, the results block rises to sit flush under the
     media and holds, dissolves, and only THEN does she enter and run the
     scrubbed frames to the line; the head (badge + title) and buttons
     arrive after she has crossed. Empty frame 1 is held the whole time
     before she runs, so nothing but the road shows while results are up. */
  .finish-film { height: 500svh; margin-top: 0; }
  .finish-stage {
    position: sticky; top: 0; height: 100svh; overflow: hidden;
    display: grid; grid-template-columns: 100%; grid-template-rows: auto auto 1fr;
    /* Round 9 (A2 item 10): the head row above still reserves its own real
       space even at opacity 0 (title fades in late), and the stage used to
       start at true y0 -- under the header. Real top padding pushes the
       WHOLE stage (head row included) down below the header first, so the
       leftover reserved gap above the image is only the title's own small
       box, not the title box plus a header-sized void on top of it. */
    padding: var(--rr-nav-h, 66px) 0 0; background: var(--black); mask-image: none; -webkit-mask-image: none;
  }
  .finish-grade, .finish-photo-band, .finish-stage::before, .finish-stage::after { display: none; }
  /* display:contents so the head/buttons become grid items of the stage
     themselves; opacity is driven per piece (JS vars on #finishCta). */
  .finish-cta { display: contents; pointer-events: auto; text-shadow: none; }
  .finish-cta-head {
    /* Round 9 (A2 item 11): badge moved onto the image itself (see
       .finish-canvas-badge below) -- this row is title-only now, so its own
       reserved height (padding + one/two lines of text) is the entire gap
       above the image, not badge + gap + title as before. */
    grid-area: 1 / 1; display: flex; flex-direction: column; align-items: center; gap: 8px;
    padding: 10px var(--page-pad) 10px; text-align: center;
    opacity: var(--finish-band-cta, 0);
    transform: translateY(calc((1 - var(--finish-band-cta, 0)) * 14px));
  }
  /* Round 12 (A2 item 15): more air above and below the title block. FILM
     MODE ONLY -- the reviewer caught this leaking into calm (height 63->77
     vs calm-base) since it was unscoped; the rule above restores the
     original 10px/10px for calm, this overrides it back to 20/24 for film. */
  html:not(.is-calm) .finish-cta-head { padding: 20px var(--page-pad) 24px; }
  .finish-canvas {
    grid-area: 2 / 1; position: relative; inset: auto; width: 100%; height: auto; aspect-ratio: 16 / 9;
    opacity: 0; transition: none;
  }
  /* Round 9 (A2 item 11): reuses the EXISTING .finish-cta-badge img (kept in
     the DOM, not a new element -- see the note on .partner-stage::before
     about desk-lock.mjs) instead of the head row it used to sit in. Placed
     with position:absolute against .finish-stage (its nearest positioned
     ancestor; .finish-cta-head has no position of its own, so it is
     skipped) at a height computed from the image's own aspect ratio
     (100vw * 9/16 = the image's rendered height at any width; 0.1125 = 20%
     of that), landing about a fifth of the way down the image -- clear of
     the painted FINISH LINE text, measured to sit in the image's own
     bottom ~25%. Fades in with the same --finish-band-cta signal as the
     title, with a short delay so it visibly follows it in. */
  .finish-cta-badge {
    display: block; position: absolute; z-index: 1; left: 50%; width: 44px; height: 44px;
    top: calc(var(--rr-nav-h, 66px) + 44px + (100vw * 0.1125));
    transform: translate(-50%, calc((1 - var(--finish-band-cta, 0)) * 10px));
    opacity: var(--finish-band-cta, 0); transition: opacity 500ms ease 120ms, transform 550ms ease 120ms;
  }
  /* Round 12 (A2 item 15): about DOUBLE the previous 44px, still clear of
     the painted FINISH LINE text -- shifted the top offset up by half the
     size increase (22px) so it grows from the same visual center instead of
     extending further down toward the text. FILM MODE ONLY -- the reviewer
     caught this leaking into calm (44->88 vs calm-base); the rule above
     keeps the original 44px for calm, this overrides it for film. */
  html:not(.is-calm) .finish-cta-badge {
    width: 88px; height: 88px; top: calc(var(--rr-nav-h, 66px) + 22px + (100vw * 0.1125));
  }
  .finish-cta h2 { font-size: clamp(1.4rem, 6.2vw, 1.7rem); line-height: 1.1; text-align: center; }
  .finish-cta h2 .finish-cta-icon { display: none; }
  .results-overlay {
    grid-area: 3 / 1; align-self: start; position: relative; inset: auto; z-index: 2; height: auto;
    display: block; padding: 6px var(--page-pad) 0; text-shadow: none;
    opacity: var(--finish-band-results, 0); transform: translateY(var(--finish-res-y, 0px));
  }
  .results-inner { grid-template-columns: 1fr; gap: 0; padding: 0; width: 100%; background: none; }
  .results-panel { gap: 16px; }
  /* Round 12 (A2 item 13): eyebrow + title move UP onto the finish-line
     image itself (bottom-left), pulled out of the black word-band below via
     position:absolute against .finish-stage (same technique as
     .finish-cta-badge just above: top computed from the image's own
     16:9-aspect rendered height, landing low on the image, clear of the
     painted FINISH LINE text which sits in the image's bottom ~25%). Own
     entrance/exit signal (--finish-res-head) instead of sharing
     --finish-band-results with the stats below, so item 14's "eyebrow+title
     fade away first, then the stats" ordering is two real variables, not
     one shared timeline. */
  /* Round 12 (A2 item 13), FILM MODE ONLY: eyebrow + title move UP onto the
     finish-line image itself (bottom-left). The reviewer caught this leaking
     into calm (position:absolute/opacity:0 made the calm label/title
     invisible, since calm has no --finish-res-head JS ever setting a
     non-zero value -- updateFinishPhone bails on isCalm()). Scoped
     html:not(.is-calm) so calm keeps its original static/opacity:1 layout,
     matching calm-base.json exactly. */
  html:not(.is-calm) .results-panel .eyebrow, html:not(.is-calm) .results-panel h2 {
    /* Bottom-left of the image, per item 13 -- max-width (not right:page-pad)
       so the block never reaches the right edge where the fixed chat bubble
       sits (caught live: the eyebrow overlapped it at 375x667, y=12000).
       Bug fix: `top` was computed as if this resolved against .finish-stage
       (using --rr-nav-h as if measuring from its very top), but the actual
       containing block is .results-overlay itself (position:relative,
       grid-area 3/1 -- BELOW the image, not the stage) -- caught live via a
       full screenshot sheet, the text was landing well into the black area
       under the stats, not on the image at all. .results-overlay's own top
       edge sits exactly at the image's bottom edge (sequential grid rows),
       so a NEGATIVE top pulls up from there by a fraction of the image's
       own height instead. */
    position: absolute; z-index: 2; left: var(--page-pad); max-width: 68%; margin: 0;
    top: calc((100vw * 9 / 16) * -0.4);
    opacity: var(--finish-res-head, 0);
    transform: translateY(calc((1 - var(--finish-res-head, 0)) * 10px));
    transition: opacity 450ms ease, transform 500ms ease;
    text-shadow: 0 2px 10px rgba(0, 0, 0, .7);
  }
  /* Reviewer fix: font-size belongs to BOTH modes (this is what made
     calm-base.json's h2 25.4px at 390px width) -- only `top` is
     film-specific (positions it on the image). Splitting them back out
     keeps calm's own text size correct instead of falling through to the
     desktop clamp's 2.4rem floor (38.4px, far too large for phone). */
  .results-panel h2 { font-size: clamp(1.4rem, 6.5vw, 1.8rem); }
  html:not(.is-calm) .results-panel h2 { top: calc((100vw * 9 / 16) * -0.4 + 18px); }
  /* Same split as above: margin-bottom:0 belongs to both modes too (also
     part of the original unscoped round-11 phone rule); only the film-only
     `top` offset was ever mode-specific. */
  .results-panel .eyebrow { margin-bottom: 0; }
  /* Stats+button no longer share vertical space with the (now absolutely
     positioned, removed-from-flow) eyebrow/title, so .results-panel's first
     real in-flow child is .stat-row -- it naturally sits right at the top of
     the black area below the image now, no extra spacer needed. */
  .stat-row { justify-content: space-between; }
  /* Round 9 (A2 item 10): "Chip", "Race day", "5" come in timed one by one
     instead of together, then "Look up your time" -- own opacity/transform
     per element (reusing the shared --finish-band-results signal already
     driving the block's rise), staggered transition-delay, same technique
     as desktop's own .stat:nth-child stagger and .partner-list li's. */
  .stat-row { gap: .75rem; margin: 0 0 12px; }
  .stat {
    padding-left: .5rem; opacity: var(--finish-band-results, 0);
    transform: translateY(calc((1 - var(--finish-band-results, 0)) * 10px));
    transition: opacity 450ms ease, transform 500ms ease;
  }
  .stat:nth-child(2) { transition-delay: 110ms; }
  .stat:nth-child(3) { transition-delay: 220ms; }
  .stat strong { font-size: 1.3rem; }
  .stat span { font-size: .62rem; }
  .results-panel > a.button {
    opacity: var(--finish-band-results, 0);
    transform: translateY(calc((1 - var(--finish-band-results, 0)) * 10px));
    transition: opacity 450ms ease 330ms, transform 500ms ease 330ms;
  }
  .results-side { display: none; }
  .finish-cta .button-row {
    grid-area: 3 / 1; align-self: start; z-index: 2; flex-direction: column; width: 100%; gap: .6rem;
    margin: 22px 0 0; padding: 0 var(--page-pad);
    opacity: var(--finish-band-btn, 0);
    transform: translateY(calc((1 - var(--finish-band-btn, 0)) * 14px));
  }
  .finish-cta .button-row .button { width: 100%; min-height: 44px; }
  /* Round 9 (A2 items 10/11): two cues, real elements now (see the note on
     .scroll-cue near the top of this block) -- the finish-image cue while
     she is still running, "Continue" once the closing call has arrived
     (.is-called, already toggled by updateFinishPhone). Both sit in
     .finish-stage's own box (grid-area unset = auto-placed, but position:
     absolute takes them out of grid flow so placement doesn't matter). */
  /* Bug fix: .scroll-cue's own `animation: scrollCuePulse` continuously
     drives opacity between .6 and 1 -- a running CSS animation always wins
     over a plain declared opacity value for the same property, so the
     opacity:0/1 toggles below were being silently ignored and BOTH cues
     rendered at once, garbled together (caught live: both read ~0.78
     opacity, a mid-pulse value, at the same scroll position). Toggling the
     animation itself off (which locks opacity at the value declared
     alongside it) instead of just the opacity value fixes it. */
  #finishRunCue { animation: scrollCuePulse 2s ease-in-out infinite; }
  .finish-film.is-called #finishRunCue { animation: none; opacity: 0; }
  #finishContinueCue { animation: none; opacity: 0; }
  .finish-film.is-called #finishContinueCue { animation: scrollCuePulse 2s ease-in-out infinite; opacity: 1; }

  /* Calm mode on phone stays static, no pins -- updatePartnerPhone() and
     updateFinishPhone() both skip when isCalm(), so without this the sticky
     media stages above would still be sticky (a CSS media query knows
     nothing about the .is-calm class) with no JS driving their fade-in
     vars, landing on an invisible band (opacity defaults to 0) instead of
     the intended plain readable stack. Reuses the same poster-image /
     static-stack technique as film mode's fallback did before this round. */
  .is-calm .partner-media-band { height: auto; }
  .is-calm .partner-media-sticky { position: static; height: auto; overflow: visible; }
  .is-calm .partner-stage {
    height: 40svh;
    background: linear-gradient(rgba(0, 0, 0, .45), rgba(0, 0, 0, .45)), url("assets/poster-foot.jpg") center / cover no-repeat;
  }
  .is-calm .partner-canvas { display: none; }
  .is-calm .partner-media-sticky #partnerCopy { opacity: 1; transform: none; }
  .is-calm .partner-media-sticky #partnerCopy .partner-heading,
  .is-calm .partner-media-sticky #partnerCopy .partner-list li,
  .is-calm .partner-media-sticky #partnerCopy .button-row { opacity: 1; transform: none; transition: none; }
  /* Calm's static stack isn't height-boxed (no overflow:hidden sticky
     viewport), so the second button fits fine -- bring it back. */
  .is-calm .partner-media-sticky #partnerCopy .button-row a:last-child { display: inline-flex; }
  .is-calm .finish-stage {
    position: static; height: auto; padding: 48px var(--page-pad) 64px; gap: 24px;
  }
  .is-calm .finish-canvas { display: none; }
  .is-calm .finish-stage::before { display: none; }
  .is-calm .results-overlay, .is-calm .finish-cta {
    position: static; height: auto; opacity: 1; pointer-events: auto; padding: 0;
  }
  /* Round 7: the pinned choreography makes .finish-cta display:contents (its
     head + buttons are grid items of the stage); calm is a plain flex stack
     again, so restore the box and show both pieces at rest. */
  .is-calm .finish-cta { display: block; }
  .is-calm .finish-cta-head, .is-calm .finish-cta .button-row { opacity: 1; transform: none; padding-left: 0; padding-right: 0; }
  .is-calm .finish-cta-head { padding-top: 0; }
  .is-calm .finish-cta .button-row { margin-top: 16px; }
  .is-calm .finish-photo-band {
    display: block; height: 28svh; min-height: 10rem; border-radius: .75rem;
    background: url("assets/seq2/finish/182.jpg") center bottom / cover no-repeat;
  }

  /* ---- 8. CONTACT: single column, 2-line heading, full-width buttons.
     Chat bubble hidden while this section is on screen or a field has
     focus -- see updateChatBubbleForForms() in main.js. ---- */
  /* Round 7 (A2 item 3): 72px below the form before community starts. */
  .contact { display: block; padding: 48px var(--page-pad) 72px; }
  .contact-intro { margin-bottom: 24px; }
  .contact-intro h2 { font-size: clamp(1.7rem, 7.5vw, 2.1rem); }
  .contact-form { grid-template-columns: 1fr; gap: 16px; padding: 20px; }
  .form-actions { flex-direction: column; }
  .form-actions .button { width: 100%; }

  /* ---- 9. COMMUNITY: the 44rem block already stacks photo/corner/chips in
     normal flow; this just tunes the photo to the spec's 4:3 and keeps the
     gutter consistent with the rest of this phase. ---- */
  /* Round 6 fix: dropped the padding-left/right:0 overrides here entirely --
     they were wiping the gutter the existing 44rem block already sets
     (`padding: 1.75rem var(--page-pad) ...`), which is exactly what clipped
     "RUN WITH" at the right edge (text-align:right with no right padding
     runs straight to the edge). Nothing left to override; the 44rem rule is
     correct as-is. */
  /* Round 12 (A2 item 17): photo locks at the top of the screen (sticky
     under the header) instead of scrolling normally, while the caption
     pieces below/over it stagger in (see #communityPin.is-in below). */
  /* Round 12 (A2 item 17): photo locks at the top of the screen (sticky
     under the header) while corner/chips scroll past below it. Chased a
     false lead here first -- the actual overlap bug (caption landing on top
     of .community-info at various scroll positions, however much
     margin-bottom was added) was updateCommunityExitPhone() in main.js
     setting `transform` on .community-corner (.community-caption's intended
     containing block, see the comment there), which silently re-anchors any
     position:absolute descendant to that new transform context per spec.
     Fixed at the source (opacity only on .community-corner, no transform),
     confirmed clean via overlap.mjs across the full scroll range -- sticky
     itself was never the problem. */
  .community-video { aspect-ratio: 4 / 3; height: auto; }
  /* Round 12 (A2 item 17), FILM MODE ONLY: photo locks at the top of the
     screen while corner/chips scroll past below it. Reviewer caught this
     leaking into calm (position:sticky vs calm-base's static) -- calm's own
     .is-calm .community-pin override (elsewhere) already drops the whole
     pin/photo pinning story entirely, so sticky here served no purpose in
     calm and only created a diff. */
  html:not(.is-calm) .community-video { position: sticky; top: var(--rr-nav-h, 66px); z-index: 1; }
  /* Round 6 (A2 item 2): eyebrow+title move onto the photo itself, upper
     right, clear of the group (there's sky above them in every candidate
     photo per Dylan) -- .community-pin becomes the positioning context so
     .community-caption's absolute box lands against the PIN's top (where
     the photo starts), not .community-corner's own box (which sits after
     the photo in flow). Description+button stay put in .community-corner,
     now visually "the black area below the photo" since the caption no
     longer occupies space there. */
  .community-pin { position: relative; }
  /* Round 12 (A2 item 18): small plain text, bottom-right corner of the
     photo -- no box, no overlay, per Dylan. .community-pin (just above) is
     already the positioning context the caption uses, so this anchors the
     same way. */
  .community-freetag {
    display: block; position: absolute; z-index: 2; right: var(--page-pad); bottom: 10px;
    margin: 0; font-size: 11px; color: rgba(255, 255, 255, .75);
    text-shadow: 0 1px 6px rgba(0, 0, 0, .7);
  }
  .community-caption {
    position: absolute; top: 14px; right: var(--page-pad); z-index: 2;
    max-width: 72%; text-align: right; padding: 10px 14px 12px; border-radius: 10px;
    /* Darkest behind the words (top-right, over the bright sky), fading toward the group. */
    background: radial-gradient(ellipse at 100% 0%, rgba(0, 0, 0, 0.62), rgba(0, 0, 0, 0.35) 55%, rgba(0, 0, 0, 0) 85%);
    text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
  }
  .community-caption .eyebrow { margin-bottom: 0.4rem; }
  .community-caption h2 { font-size: clamp(1.5rem, 6.5vw, 1.9rem); }
  /* Corner block no longer needs the 1.75rem top clearance the tablet rule
     reserved for the caption that used to live here -- info moves up into
     that freed space, evenly spaced from the photo above it. */
  .community-corner { padding-top: 0.75rem; }
  .community-corner .button { margin-top: 1rem; }
  /* Round 9 (A2 item 12): center the description line and the "Join the
     community" button on phone -- the caption (eyebrow + title) stays
     right-aligned over the sky as before, only .community-info centers. */
  .community-info { text-align: center; }
  .community-info .button { margin-left: auto; margin-right: auto; }
  /* Round 6 (A2 item 3): the desktop `align-items:end` grid rule sizes each
     chip to its own content, so the two headings in a row ("Race Day Info"
     vs "Results and Recaps") land at different heights on phone's tighter
     2-column width -- stretch makes every tile in a row match the tallest,
     and a shared min-height keeps both rows visually equal too. Tap-to-expand
     itself (JS toggles .is-open, only that tile's .chip-body grows) is
     unchanged and already targets the whole card, which is a comfortable
     tap target on its own -- min-height just guarantees it never runs short. */
  .community-chips { align-items: stretch; }
  .chip { min-height: 5.75rem; display: flex; flex-direction: column; justify-content: flex-start; }
  .chip-head svg { width: 1.3rem; height: 1.3rem; }
  .chip-head h3 { font-size: 0.8rem; }

  /* ---- 10. BLOG: native horizontal scroll-snap carousel, 82vw cards, no
     scroll-driven transform -- finger-swipeable, no page-scroll hijack.
     data-carousel on the track (index.html) excludes it from overlap.mjs's
     CLIPPED-X check, since cards are partly off-screen by design. ---- */
  /* .blog-head/.blog-grid/.blog-foot are inside .blog's own .section-shell
     wrapper, which already supplies var(--page-pad) side gutter -- head/foot
     need no padding of their own (that would double it to ~40px); the
     carousel track cancels the inherited gutter with a negative margin and
     re-adds its own, the standard "bleed past the section padding" pattern,
     so the peeked next card sits flush with the rest of the page's gutter. */
  .blog { padding: 64px 0; }
  .blog-head { flex-direction: column; align-items: flex-start; gap: 1rem; margin-bottom: 1.5rem; }
  /* Round 6 (A2 item 4): "All posts" competed with the row of blog-card
     buttons below it for weight -- drops the button chrome for a plain text
     link on phone, and the 1rem (16px) gap above (was .75rem) gives "Race
     notes." real breathing room instead of the button crowding the heading. */
  .blog-head .blog-all {
    border: 0; background: none; padding: 0; min-height: 0;
    color: var(--gold); font-size: .8rem; text-decoration: underline; text-underline-offset: 3px;
  }
  .blog-grid {
    display: flex; grid-template-columns: none; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory;
    padding: 0 var(--page-pad) 4px; margin: 0 calc(var(--page-pad) * -1); -webkit-overflow-scrolling: touch;
  }
  .blog-card { flex: 0 0 82vw; scroll-snap-align: start; }
  /* Round 7 (A2 item 4): prev/next for the swipe carousel, right-aligned
     above the cards. 40px gold-outline circles; the dimmed disabled state
     shows at either end. main.js scrolls the track by one card. */
  .blog-nav { display: flex; justify-content: flex-end; gap: 10px; margin: -0.5rem 0 14px; }
  .blog-arrow {
    display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0;
    border: 1.5px solid var(--gold); border-radius: 50%; background: transparent; color: var(--gold); cursor: pointer;
    transition: opacity 200ms ease, background 200ms ease;
  }
  .blog-arrow svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
  .blog-arrow:active:not(:disabled) { background: rgba(212, 167, 44, 0.16); }
  .blog-arrow:disabled { opacity: .3; cursor: default; }

  /* ---- 11. NEWSLETTER: single column, full-width Subscribe. ---- */
  /* Round 12 (A2 item 20): the base rule's border-top reads as a visible
     line between blog and newsletter -- both already share the same solid
     black background, so removing it (phone only) lets the two feel like
     one combined section instead of two visually separated ones. */
  .newsletter { padding: 48px 0; border-top: none; }
  .newsletter-band { grid-template-columns: 1fr; gap: 24px; }
  .newsletter-copy h2 { font-size: clamp(1.7rem, 7.5vw, 2.1rem); }
  .newsletter-form { grid-template-columns: 1fr; gap: 16px; }

  /* ---- Round 7 (A2): phone scroll-in animations. All one-shot, all off the
     IntersectionObserver in main.js that adds .is-in (data-phone-reveal), all
     `:not(.is-calm)` so calm stays a plain static page. ---- */
  .js:not(.is-calm) .a2-pr {
    opacity: 0; transform: var(--ph-from, translateY(1.5rem));
    transition: opacity 650ms ease, transform 750ms cubic-bezier(.2, .7, .2, 1); transition-delay: var(--ph-d, 0ms);
  }
  .js:not(.is-calm) .a2-pr.is-in { opacity: 1; transform: none; }
  .stat strong { white-space: nowrap; }
  /* Community: photo fades in, then eyebrow and title one by one (120ms
     stagger, fade + rise), then the description and button, then the four
     chips one at a time -- all keyed off #communityPin.is-in. The blocks
     that used the generic .reveal (caption, info, chips wrapper) are pinned
     visible here so only the pieces inside animate, in order. */
  .js .community-caption.reveal, .js .community-info.reveal, .js .community-chips.reveal {
    opacity: 1; transform: none; transition: none;
  }
  /* Round 12 (A2 item 17): was a swipe-in from the right (translateX+scale)
     -- Dylan asked for a ZOOM instead. Pure scale now, no horizontal
     movement; object-position is unaffected either way since this is a
     transform, not a position change. */
  .js:not(.is-calm) #communityPin .community-video {
    opacity: 0; transform: scale(0.9);
    transition: opacity 900ms ease, transform 900ms cubic-bezier(.2, .7, .2, 1);
  }
  /* is-in's resting transform lives further down (--phone-community-zoom,
     item 19) instead of a plain `none` here, so the exit zoom can drive the
     same property without a second rule overriding it back to none. */
  .js:not(.is-calm) #communityPin .community-caption .eyebrow,
  .js:not(.is-calm) #communityPin .community-caption h2,
  .js:not(.is-calm) #communityPin .community-info p,
  .js:not(.is-calm) #communityPin .community-info .button {
    opacity: 0; transform: translateY(14px);
    transition: opacity 600ms ease, transform 700ms cubic-bezier(.2, .7, .2, 1);
  }
  .js:not(.is-calm) #communityPin .community-caption .eyebrow { transition-delay: 600ms; }
  .js:not(.is-calm) #communityPin .community-caption h2 { transition-delay: 720ms; }
  .js:not(.is-calm) #communityPin .community-info p { transition-delay: 950ms; }
  .js:not(.is-calm) #communityPin .community-info .button { transition-delay: 1070ms; }
  .js:not(.is-calm) #communityPin .chip {
    opacity: 0; transform: translateY(16px);
    transition-property: opacity, transform, border-color, background;
    transition-duration: 600ms, 700ms, 200ms, 200ms;
    transition-delay: var(--cd, 1250ms), var(--cd, 1250ms), 0s, 0s;
  }
  .js:not(.is-calm) #communityPin .chip:nth-child(2) { --cd: 1370ms; }
  .js:not(.is-calm) #communityPin .chip:nth-child(3) { --cd: 1490ms; }
  .js:not(.is-calm) #communityPin .chip:nth-child(4) { --cd: 1610ms; }
  .js:not(.is-calm) #communityPin.is-in .community-caption .eyebrow,
  .js:not(.is-calm) #communityPin.is-in .community-caption h2,
  .js:not(.is-calm) #communityPin.is-in .community-info p,
  .js:not(.is-calm) #communityPin.is-in .community-info .button,
  .js:not(.is-calm) #communityPin.is-in .chip { opacity: 1; transform: none; }
  /* Round 12 (A2 item 19): once the entrance finishes (was transform:none,
     same as the rest above), the resting transform is driven by
     --phone-community-zoom instead, so the exit's further zoom-in
     (updateCommunityExitPhone in main.js) can animate the SAME declared
     transition property without a separate rule fighting it. Defaults to 1
     (identical to the old `none`) until the exit sets a higher value. */
  .js:not(.is-calm) #communityPin.is-in .community-video {
    opacity: 1; transform: scale(var(--phone-community-zoom, 1));
  }

  /* Vertical rhythm: 64px between sections (the section-level padding above
     already gives most of this), 24px heading-to-content, 16px between
     stacked items -- applied per-section above rather than as one generic
     utility class, since none of these sections share a common wrapper. */
}
/* ===== PHONE (<=600px): hero, races, steps, road ===== */
/* A1's zone per mobile-spec.md. All rules media-scoped so desktop (>=601px)
   and calm mode's own separate .is-calm rules (which already produce the
   "clean stacked" layout at any width) are untouched. updateRaces/
   updateServices/updateRoad hard-return on PHONE.matches (main.js) -- their
   desktop pin/opacity vars are simply never set below this width, so this
   block does not need to fight any inline JS-driven style. */
@media (max-width: 600px) {
  /* ---- Hero: film pattern, media window (~61svh) + word band (~42svh) ----
     Reuses the SAME opacity/transform custom properties updateHero() already
     computes (--opening-alpha/--middle-alpha/--start-alpha/--lines-alpha/
     --card-alpha/--card-content-alpha etc, still driven by the unmodified
     desktop JS) -- only the CONTAINER positioning changes, from "centered
     over the full-bleed film" to "docked in the band below it". */
  /* ~60% of desktop's HERO_VH (1199vh) per mobile-spec's "shorter scroll
     lengths" guidance -- same JS progress math (updateHero uses
     getBoundingClientRect on this section), just less scroll to get there. */
  .hero { height: 505vh; } /* Angel 9/29: was 720vh */
  /* 100vh is Safari's toolbar-hidden height, so with the toolbars showing the bottom of the band (the hero buttons)
     sat under the browser chrome (Dylan, iPhone, 2026-10-03). svh = the height with toolbars showing. */
  .hero-stage { height: 100svh; min-height: 100svh; }
  /* Round 9 follow-up, item 1: the film sat UNDER the fixed header (Dylan's
     images/5.png complaint) since this box started at top:0 while the
     header floats on top of it. Offset by --rr-nav-h (chrome.css) so the
     whole 61svh window is visible below the header; the band below shrinks
     by the same amount since it starts where the media window ends. */
  .hero-fallback, .hero-canvas, .hero-intro, .hero-intro-video {
    top: var(--rr-nav-h, 66px); height: 61svh; bottom: auto;
  }
  /* Round 12, item 1: opaque black backdrop on the intro layer so its first
     painted frame (before the video element has actually decoded pixels)
     never lets the settled-pose canvas underneath show through as a strip --
     the video's own object-fit:cover crop can lag the layout by a frame or
     two on load, and without this the canvas (same still frame the site
     later settles on) was visible through that gap, reading as a seam at
     the title. Phone-only: desktop's own .hero-intro rule is untouched. */
  .hero-intro { background: #000; }
  /* Dylan explicitly does not want a dark wash over her on phones -- the base
     .hero-scrim gradient (a bottom-38% dark band plus a full-box --tint wash
     up to 0.4 alpha, tuned for a full 100vh hero) reads as very dark once
     squeezed into a 61svh window, since the dark band and tint now cover
     proportionally much more of what's visible. Replaced with only the
     spec's own "soft ~40px fade into the band" at the very bottom edge, no
     tint wash at all -- same brightness as desktop everywhere above that. */
  .hero-scrim {
    top: var(--rr-nav-h, 66px); height: 61svh; bottom: auto;
    background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0, transparent 40px);
    /* The OLD 704px phone block (still matches down here too) also puts an
       inset box-shadow on .hero-scrim (-18rem offset, 12rem blur, 0.95
       alpha) sized for the old full-100vh mobile hero, where it darkened the
       area behind the bottom text. That shadow paints as its own compositor
       layer on top of whatever canvas frame is drawn -- it doesn't touch the
       canvas bitmap at all (confirmed: canvas.toDataURL() mean luminance
       88.6 vs the actual on-page rendered element mean 35.6, at the same
       scrollY/progress) -- so it was the real culprit behind "still much
       darker than desktop", not the background gradient above. Cleared here;
       the 40px bottom fade above already does this window's only intended
       darkening. */
    box-shadow: none;
  }
  /* Solid black word band, full width, no text ever painted on the media above it. */
  .hero-stage::after {
    content: ""; position: absolute; z-index: 5; left: 0; right: 0;
    top: calc(var(--rr-nav-h, 66px) + 61svh); bottom: 0;
    background: var(--black);
  }
  .hero-content, .hero-lines, .hero-cue {
    position: absolute; z-index: 6; left: 0; right: 0; top: calc(var(--rr-nav-h, 66px) + 61svh); bottom: 0;
    width: auto; display: flex; flex-direction: column; justify-content: center;
    /* Global system's chat bubble is 52px + 16px from the edge, bottom-right,
       on every page including this pinned stage -- extra right clearance
       here keeps band text/buttons out of that corner on short viewports
       (overlap.mjs UNDER-CHAT, "Plan an event"/hero-line/hero-card @ short
       heights where the band's vertical centering pushes content low). */
    padding: 0 calc(var(--page-pad) + 4.5rem) 0 var(--page-pad); text-align: left; transform: none;
  }
  /* Overlapped the opening buttons in the compact band (cue text sitting on
     the button row, frame-0 review) -- the band has no room to also fit a
     "scroll to begin" hint below the buttons with real clearance, and it's a
     desktop-only nicety, so it's simplest to just drop it on phone. */
  /* Round 9, items 2+3: brought back, bottom-left, small, through the
     opening/middle/start beats -- max() of the three scroll-driven alphas
     so it fades out during whichever beat is active, unlike the base rule's
     single --opening-alpha (which would hide it once the opening beat ends,
     leaving the "keep scrolling" beats with no cue). */
  .hero-cue {
    /* top:auto resets the shared band rule's top -- without it this box
       spanned the whole band height (a ~300px-tall flex container), which
       is what actually put the cue text over the buttons (measured: cueTop
       515px, btnTop 633px, both well inside that oversized box), not a
       spacing problem with the buttons themselves. */
    /* flex-direction:row resets the shared band rule's column direction --
       without it the icon/text/icon stacked vertically (icon+text+icon+2
       gaps = 58.5px tall, measured), which is what actually reached up into
       the buttons, not a spacing problem. */
    display: flex; flex-direction: row; align-items: center; top: auto; left: var(--page-pad); right: auto; bottom: 0.5rem; transform: none;
    gap: 0.5rem; font-size: 0.62rem;
    opacity: max(var(--opening-alpha, 0), var(--middle-alpha, 0), var(--start-alpha, 0));
  }
  .hero-cue svg { width: 0.85rem; height: 0.85rem; flex: none; }
  /* Round 9 follow-up: the text span is a flex item inside .hero-cue (a flex
     row), so it shrinks by default and was wrapping to ~4 lines at narrow
     widths (measured: 58.5px tall / 15.4px line-height at 375x667) --
     wide enough to reach up into the buttons above. nowrap keeps it one
     line, matching its small font-size and the small icons either side. */
  .hero-cue span { white-space: nowrap; flex: none; }
  /* Round 9 follow-up, item 3: Dylan's exact copy ("Scroll to start race"),
     phone-only -- the shared span keeps its own desktop text untouched
     (desktop cue is unaffected, no shared-string edit), a phone-only
     sibling span (index.html) is shown instead. Arrows were already real
     inline <svg> paths, not glyph characters, so no change needed there. */
  .hero-cue > span:not(.hero-cue-text-phone) { display: none; }
  .hero-cue-text-phone { display: inline; }
  /* Dylan round 6, item 1: no gold eyebrow on phone; the shortened
     .hero-lead-phone paragraph (index.html) folds "Texas based" in instead. */
  .hero-opening .eyebrow { display: none; }
  .hero-opening .hero-lead:not(.hero-lead-phone) { display: none; }
  .hero-lead-phone { display: block; }
  .hero-content h1 { font-size: clamp(2.1rem, 9vw, 2.6rem); }
  .hero-content h2 { font-size: clamp(1.7rem, 7.5vw, 2.1rem); }
  .hero-lead { font-size: 1rem; }
  /* Dylan round 7, item 1: H1 on three rows ("Every race" / "starts" /
     "here.") pulled OUT of the band flow (absolute, negative top = up into
     the media window, just under the header) in the open space left of her
     arm; the paragraph then leads the band directly under the blue start
     line, buttons right under it. Rides .hero-opening's own opacity, so it
     fades with the beat exactly as before. */
  /* 2026-10-01 (Dylan, phone hero): headline is TWO lines now, "Every race"
     (gold) / "starts here." (white), so .br-phone stays hidden (its global
     display:none) and "starts" + "here." share a line. The opening box is
     the black word band: lead sits right under the media window (below the
     finish line and her foot), the two buttons stack at the bottom of the
     band (margin-top:auto on the row), clear of the scroll cue. */
  .hero-opening { justify-content: flex-start; padding-top: 0.75rem; padding-bottom: 2.7rem; }
  .hero-opening h1 {
    /* Round 9, item 2: a little lower than before (was 4.5rem, then 5.5rem).
       Round 9 follow-up: .hero-opening's own box now starts at
       navH+61svh (the media offset above), so this must subtract that same
       navH to land at the same on-screen spot just under the header --
       otherwise the header height would double-count into the offset. */
    /* 2026-10-01: "Every race" starts 30% of the way down the media window
       (window = navH .. navH+61svh, horizon sits at ~24%), so both lines are
       on the tarmac, not the skyline. Relative to this box, which starts at
       navH+61svh: top = 0.30*61svh - 61svh = -42.7svh. */
    position: absolute; top: calc(0.25rem - 42.7svh); left: var(--page-pad); margin: 0;
    font-size: clamp(1.4rem, 6.3vw, 1.75rem); line-height: 1.06; /* kept narrow: clear of her shoulder */
    color: var(--white);
  }
  .hero-opening h1 span { color: var(--white); }
  .hero-opening h1 .h1-first, .hero-opening h1 .h1-line-1 { color: var(--gold); }
  /* Round 12, item 2 (revised per coordinator -- true per-line stagger):
     main.js wraps each of the title's three text pieces in a
     .h1-line-phone span (phone-only, never calm, desktop DOM untouched).
     display:inline-block so each line gets its own transform/opacity
     without disturbing the <br>-driven line breaks already in the markup.
     Film-mode only (html:not(.is-calm)) -- calm has no load/video moment,
     and the animation's `to{transform:none}` keyframe makes
     getComputedStyle report a permanent identity matrix instead of `none`,
     a real diff against calm-base.json even once fully settled -- same
     issue as the header logo above. */
  html:not(.is-calm) .h1-line-phone { display: inline-block; opacity: 0; animation: heroTitlePop 400ms ease both; }
  html:not(.is-calm) .h1-line-1 { animation-delay: 120ms; }
  html:not(.is-calm) .h1-line-2 { animation-delay: 420ms; }
  html:not(.is-calm) .h1-line-3 { animation-delay: 720ms; }
  @keyframes heroTitlePop {
    from { opacity: 0; transform: scale(0.9) translateX(-0.75rem); }
    to { opacity: 1; transform: none; }
  }
  /* Round 12, item 3: balance fix (option a) -- paragraph moves UP into the
     media window directly under the title, left-aligned, instead of sitting
     centered in the black band below where it left a dead gap over the
     image and made the band feel crowded. Absolute-positioned relative to
     the same media window the title already anchors to. */
  /* html:not(.is-calm) guard: calm mode renders this paragraph in normal
     flow with its own margin (.is-calm .hero-opening .hero-lead, existing
     rule) -- pulling it into position:absolute here shrank .hero-opening's
     flow height in calm specifically, shifting every section below it up by
     the paragraph's own height (caught by the calm lock, ~77px page-wide
     diff at 390x844). This balance change is film-mode only. */
  /* Coordinator flag (overlap.mjs, all 4 phone sizes, scroll 0): 3.35rem
     below the title's own top wasn't enough clearance -- the title is 3
     lines at up to 1.75rem/1.06 line-height (~5.6rem tall at the clamp's
     upper end), so the paragraph started inside the title's own box.
     6.35rem clears the worst-case 3-line height plus a small gap. */
  html:not(.is-calm) .hero-opening .hero-lead-phone {
    /* 2026-10-01: no longer pulled up into the media window. In normal flow at
       the top of the band, just under the finish line and her foot. */
    position: static; z-index: 6;
    /* negative right margin gives back the chat-bubble gutter: the paragraph sits well above the bubble, so on narrow phones it stays two lines. */
    margin: 0 -4.5rem 0.9rem 0; text-align: left; max-width: 19rem; font-size: 0.95rem; line-height: 1.45;
    /* Delay pushed from 320ms to 1220ms: the title now finishes its own
       3-line stagger at 720ms delay + 400ms duration = 1120ms, so the
       paragraph waits for that instead of overlapping it. */
    opacity: 0; animation: heroLeadPop 500ms 1220ms ease both;
  }
  @keyframes heroLeadPop {
    from { opacity: 0; transform: translateY(0.4rem); }
    to { opacity: 1; transform: none; }
  }
  .hero-opening .button-row {
    /* 2026-10-01: stacked, "Find a race" over "Plan an event", pinned to the
       bottom of the band. Width stops short of the chat bubble column. */
    margin-top: auto; flex-direction: column; align-items: stretch; width: min(17rem, 100%); flex-wrap: nowrap; justify-content: flex-start; gap: 0.6rem;
    /* Delay pushed from 520ms to 1720ms to follow the paragraph (1220ms
       delay + 500ms duration). */
    animation: heroButtonsPop 500ms 1720ms both;
  }
  .hero-opening .button-row a:nth-child(2) { animation-delay: 1870ms; }
  .hero-opening .button-row .button { min-height: 2.75rem; padding-block: 0.6rem; }
  html.is-calm .hero-opening .button-row { margin-top: auto; }
  @media (max-height: 700px) {
    .hero-opening { padding-top: 0.6rem; padding-bottom: 2.4rem; }
    html:not(.is-calm) .hero-opening .hero-lead-phone { font-size: 0.88rem; line-height: 1.4; margin-bottom: 0.6rem; }
    .hero-opening .button-row { gap: 0.45rem; }
    .hero-opening .button-row .button { min-height: 2.5rem; padding-block: 0.5rem; }
  }
  @media (max-height: 600px) {
    .hero-opening { padding-top: 0.5rem; padding-bottom: 0.9rem; }
    html:not(.is-calm) .hero-opening .hero-lead-phone { font-size: 0.8rem; line-height: 1.3; margin-bottom: 0.5rem; }
    .hero-opening .button-row { gap: 0.4rem; }
    .hero-opening .button-row .button { min-height: 2.25rem; padding-block: 0.4rem; }
    .hero-cue { display: none; }
  }
  @keyframes heroButtonsPop {
    from { opacity: 0; transform: scale(0.85) translateY(0.5rem); }
    to { opacity: 1; transform: none; }
  }
  /* Dylan round 7, item 2: each line of "We run the day. / You run the race."
     on ONE row, and "Your start line / is waiting." exactly two rows: nowrap
     plus a clamp sized to fit 360px, and the bubble's extra right padding is
     dropped for these two (they sit mid-band, well above the bubble). */
  /* Round 9, item 3: sits at the TOP of the band (just under the film),
     not centered in the black -- overrides the shared band rule's
     justify-content:center for these two beats only. */
  .hero-middle, .hero-start { padding-right: var(--page-pad); justify-content: flex-start; padding-top: 1.75rem; }
  .hero-middle h2, .hero-start h2 { white-space: nowrap; }
  .hero-middle h2 { font-size: clamp(1.15rem, 6.4vw, 1.75rem); }
  .hero-start h2 { font-size: clamp(1.4rem, 7vw, 2rem); }
  /* Dylan round 7, item 3: after the cards the film never returns. The
     existing .hero-exit wash becomes a scrubbed pure-black layer (--phone-black,
     set in updateHero from the morph) so the launch frames, foot and pavement
     are gone for the rest of the hero. */
  .hero-exit { background: #000; opacity: var(--phone-black, 0); }
  /* Round 9 follow-up, item 4: a real scroll-driven rise, not a static
     repositioning. --line-rise is set by updateHero() (main.js) from the
     SAME lineIns[] progress that already drives --line-in's entrance/opacity
     during the run phase, but only behind PHONE.matches -- 0 on desktop, so
     this new custom property never touches desktop's own transform. As she
     runs (lineIns 0 -> 1) each line translates up to 30vh into the film,
     stacked with the existing small entrance nudge from the base rule
     (translateY((1-line-in)*1rem)); text-shadow already handles legibility
     over the film (base .hero-line rule), no background box added. */
  .hero-line {
    font-size: clamp(1.5rem, 8vw, 2.1rem);
    transform: translateY(calc(((1 - var(--line-in, 0)) * 1rem) - (var(--line-rise, 0) * 30vh)));
  }
  /* Beat g (Dylan round 6, item 2): once she's gone, the cards are real
     feature cards using the WHOLE screen, not the 42svh band -- overrides
     the shared band positioning rule above (top:0/bottom:0 instead of
     top:61svh), so this needs its OWN position rule rather than joining the
     .hero-content/.hero-lines/.hero-cue selector. z-index 7, above the
     word-band's own black underlay (.hero-stage::after, z5) and the band
     content (z6), since the cards now cover that same area too. */
  .hero-cards {
    position: absolute; z-index: 7; left: 0; right: 0; top: 0; bottom: 0;
    display: flex; flex-direction: column; justify-content: center; align-items: stretch;
    /* Tightened chrome (Dylan direct verification, 375x667/360x780): the
       worst-case card body (longest description) needs more room than an
       even 3-way split of the old padding/gap left over -- squeezing this
       whitespace (not text, not image) is the only remaining lever once
       the photo is already down to 0. */
    gap: 0.2rem; padding: var(--rr-nav-h, 66px) var(--page-pad) 0.25rem; text-align: left; transform: none;
  }
  /* Round 12, item 6: all three still don't fully fit (card 3 + its button
     clipped) -- shrink the photo footprint and padding further (text/fonts
     last resort, trimmed minimally), and add the faint film-behind-text tint
     Dylan asked for on the card background. Calm mode renders this SAME
     #heroCards markup in its own static stack (confirmed: it showed up in
     the calm-lock diff, #heroCards height 742->665), so these sizing changes
     are guarded to film-mode only (html:not(.is-calm)) to keep calm's own
     card layout exactly as locked. */
  html:not(.is-calm) .hero-cards { gap: 0.15rem; padding-top: calc(var(--rr-nav-h, 66px) - 6px); padding-bottom: 0.15rem; }
  html:not(.is-calm) .hero-card-photo { max-height: 2.75rem; }
  html:not(.is-calm) .hero-card-body { padding: 0.45rem 1.4rem 0.45rem; gap: 0.28rem; }
  html:not(.is-calm) .hero-card-title { font-size: 1.32rem; }
  html:not(.is-calm) .hero-card-desc { font-size: 0.75rem; line-height: 1.32; }
  html:not(.is-calm) .hero-card-btn { margin-top: 0.2rem; padding: 0.4rem 0.8rem; }
  html:not(.is-calm) .hero-card { background: rgba(0, 0, 0, 0.85); }
  /* Round 9, item 5: small gold eyebrow above the three cards, fades with
     them (gated on the same [aria-hidden] toggle the cards' own pointer-
     events already use). A ::before pseudo-element on purpose, not a real
     DOM node: the coordinator's desktop lock diffs every element on the page
     by a sequential document-order index, so inserting even a hidden real
     element anywhere before the races/steps/road content shifts every later
     index and reads as a wall of false "desktop changed" diffs. A
     pseudo-element is invisible to that walk (querySelectorAll('body *')
     never returns it) and to desktop (only styled under this phone query). */
  .hero-cards::before {
    content: "Where do you want to start?"; order: -1; margin: 0 0 0.5rem;
    color: var(--gold); font-size: 0.72rem; font-weight: 700;
    letter-spacing: 0.1em; text-transform: uppercase; text-align: center;
    opacity: 0; transition: opacity 400ms ease;
  }
  .hero-cards[aria-hidden="false"]::before { opacity: 1; }
  /* Desktop's card-exit beat swipes 01/03 sideways off a wide 3-across row
     (updateHero's --card-exit -> --card-out-x, unchanged, still computed on
     phone). In a narrow vertical stack that same sideways translate sweeps a
     still-partly-opaque card across its still-visible neighbour mid-flight
     (overlap.mjs OVERLAP/CLIPPED-X/UNDER-CHAT, c-3900 to c-4900 across all 4
     test widths) -- same "mid-exit-swipe" class the desktop review already
     accepts as transitional, but exaggerated here by the column layout, so
     it's cleaner to just drop the horizontal component on phone and let the
     existing --card-alpha opacity fade (still driven by the same JS) do the
     exit on its own. */
  .hero-card:nth-child(1), .hero-card:nth-child(3) { --card-out-x: 0; }
  /* Real feature cards now (photo as a left block, number/title/one-liner
     stacked beside it), not compact rows -- big enough to read as proper
     boxes, sized to fit all three between the header and the bottom with
     even spacing at both 390x844 and 375x667 (flex:1 + the container's
     justify-content:center + 1rem gap does the even-spacing; min-height
     caps how big a card gets on the taller 430x932 test size). */
  /* Dylan direct (all widths): text/button locked, never shrunk or hidden --
     removed the old row layout (photo-left/body-right) and its font-size
     overrides entirely. Cards are a column (base rule) sharing the stack's
     own definite height equally (flex:1 1 0, min-height:0), same lever as
     the tablet stack above; the photo (base rule, flex:1 1 auto) is what
     actually shrinks as the viewport gets shorter or narrower. */
  /* flex-basis:auto (not 0) -- each card's own natural content height
     (mostly its body, since the empty photo contributes ~0 unconstrained)
     is the starting point; grow/shrink then distributes only the
     difference, weighted by each card's own size, instead of forcing an
     equal 3-way split that shortchanged the card with the longest
     description (measured overflow at 375x667 with flex:1 1 0; auto fixed
     it, since the container has just enough total room when distributed
     this way -- see the Dylan-direct verification script). */
  .hero-card { width: auto; flex: 1 1 auto; min-height: 0; height: auto; }
  /* Tightened body padding/gap on phone (chrome, not text) -- with 3 stacked
     cards sharing one screen, the longest description's natural body height
     needed more room than the base 1.5rem/1.6rem/1.65rem padding + 0.6rem
     internal gaps left after an even 3-way split (measured overflow at
     375x667/360x780 via the Dylan-direct verification script). */
  /* Horizontal padding kept at the base 1.6rem: narrowing it wraps the
     description onto an extra line, which COSTS more height than tighter
     vertical padding saves. Vertical padding here must be less than the
     OLD 704px block's own 0.85rem/1rem (styles.css ~1274, which was already
     the winning value before this rule existed) or it's a net regression,
     not a tightening -- confirmed by measurement. */
  .hero-card-body { padding: 0.6rem 1.6rem 0.6rem; gap: 0.35rem; }
  /* Dylan round 6, item 3: cards now cover the whole stage (item 2), so the
     media behind them must already be settled to plain black by the time
     they're interactive -- gated on the same [aria-hidden="false"] JS
     already toggles for pointer-events during exactly this beat (see the
     base .hero-cards[aria-hidden="false"] rule), so no new JS/timing
     constants needed. Solid black, no hue -- matches "pure black behind"
     for the races heading right after it too (.section-races below). */
  .hero-cards[aria-hidden="false"] { background: var(--black); }

  /* ---- Races: not pinned. Static legs video, heading, native carousel ---- */
  /* -100vh (same overlap distance as desktop's own crossfade, see the Round 5
     comment on .hero-stage above) so this box's video paints over hero's
     sticky release tail instead of appearing below a literal blank/dark gap
     once hero finally scrolls away -- .section-races already carries the
     higher z-index (base rule, unchanged). Removing this overlap entirely
     is what produced the near-black screen with only a video sliver at the
     bottom (frame ~5400 review): hero's pavement-zoom hold ran its course
     with nothing painted over it, then races only entered the normal way. */
  /* -100vh (the full desktop-style overlap) pulled races' heading into view
     while the hero card rows were still mid-fade (measured: at 390x844, the
     card row opacity only reaches 0 for all three by scrollY~5100, but with
     a full -100vh overlap "Our races." was already on screen by ~4721 --
     card01 "02 Time your event" was still ~0.74 opaque, genuinely stacked
     over the appearing heading, review frame ~5600). -45vh still closes the
     black-gap hand-off (races' video starts appearing well before hero's
     100vh sticky release tail finishes) without entering the viewport until
     after the cards have finished fading, at every one of the 4 test widths
     (checked 390x844, 375x667, 360x780, 430x932 -- proportional since both
     the card-exit progress and this overlap are vh-relative). */
  .section-races { margin-top: -45vh; height: auto; padding: 4rem 0; background: var(--black); }
  .races-pin { position: static; height: auto; min-height: 0; overflow: visible; opacity: 1; transform: none; }
  /* Dylan round 7, item 3: no legs video image on phone -- "Our races."
     rises straight out of the black. */
  .races-bg { display: none; }
  .races-bg-video, .races-bg-grade { opacity: 1; }
  .races-head { position: static; margin: 0 var(--page-pad) 1.5rem; }
  /* Dylan round 7, item 4: a plain vertical stack of full-width cards, no
     scroll-snap track / horizontal scroll (data-carousel removed in HTML). */
  .races-track {
    position: static; display: flex; flex-direction: column; overflow: visible; scroll-snap-type: none;
    gap: 1rem; padding: 0 var(--page-pad); margin: 0; opacity: 1; transform: none;
  }
  .race-tile { flex: 0 0 auto; width: 100%; min-height: 12rem; scroll-snap-align: none; }
  /* Rise + fade reveals (IntersectionObserver in main.js adds .is-in). */
  .steps-head[data-phone-reveal] {
    opacity: 0; transform: translateY(1.5rem); transition: opacity 700ms ease, transform 700ms ease;
  }
  /* Round 12, item 7: races entrance changed from a rise to a swipe-in from
     the left, one piece at a time (eyebrow+title as one head block, then
     each race card), timed via transition-delay stagger rather than pulled
     by scroll position -- same IO+CSS-transition mechanism as everywhere
     else on phone (main.js's generic phoneRevealObserver), just a left-swipe
     transform and per-item delays instead of a uniform rise. */
  /* Keeps the ORIGINAL (pre-round-12) rule/value here, unguarded, so calm's
     tie-break against its own earlier `.is-calm .races-head{opacity:1;
     transform:none}` reset (line ~1488, equal (0,2,0) specificity, a later
     equally-specific rule wins on source order) resolves EXACTLY as it did
     in the locked calm baseline -- deleting or reshaping this rule changed
     what calm ties against and produced a real diff (caught by the calm
     lock: racesHead settled at opacity 1/no transform instead of the
     baseline's own opacity 0/translateY(24px) pre-reveal state). The film
     swipe-in below is a separate, MORE specific (0,3,0) rule that only
     matches outside calm, so it wins there without touching this one. */
  .races-head[data-phone-reveal] {
    opacity: 0; transform: translateY(1.5rem); transition: opacity 700ms ease, transform 700ms ease;
  }
  html:not(.is-calm) .races-head[data-phone-reveal] {
    opacity: 0; transform: translateX(-2.25rem);
    transition: opacity 550ms ease, transform 550ms ease;
  }
  /* race-tile is already protected the same way by calm's own existing
     `.is-calm .race-tile[data-phone-reveal]` override (higher specificity,
     0,3,0), so only the head needed this guard -- but match it here too for
     the same tie-breaking reason, and to keep both entrance rules under one
     consistent pattern for this round. */
  html:not(.is-calm) .race-tile[data-phone-reveal] {
    opacity: 0; transform: translateX(-2.25rem);
    transition: opacity 500ms ease, transform 500ms ease, border-color 260ms ease, box-shadow 260ms ease;
  }
  .race-tile[data-phone-reveal]:nth-of-type(1) { transition-delay: 0ms; }
  .race-tile[data-phone-reveal]:nth-of-type(2) { transition-delay: 110ms; }
  .race-tile[data-phone-reveal]:nth-of-type(3) { transition-delay: 220ms; }
  .race-tile[data-phone-reveal]:nth-of-type(4) { transition-delay: 330ms; }
  .race-tile[data-phone-reveal]:nth-of-type(n + 5) { transition-delay: 440ms; }
  .races-head[data-phone-reveal].is-in, .steps-head[data-phone-reveal].is-in,
  .race-tile[data-phone-reveal].is-in { opacity: 1; transform: none; }
  /* REGRESSION FIX (reviewer BLOCKER): the two html:not(.is-calm) swipe-in
     rules above have specificity (1 type, 3 classes), which BEATS the plain
     `.is-in` rule directly above (0 types, 3 classes) once classes tie --
     type-selector count is the next tiebreaker, and 1 > 0. That meant the
     pre-reveal opacity:0 state never let go once revealed: races head and
     every race tile stayed permanently invisible regardless of scroll,
     exactly Dylan's earlier "races list does not populate" bug. Fixed by
     giving the revealed state the SAME html:not(.is-calm) prefix, so it
     outranks the pre-reveal rule the same way it outranks the one below it. */
  html:not(.is-calm) .races-head[data-phone-reveal].is-in,
  html:not(.is-calm) .race-tile[data-phone-reveal].is-in { opacity: 1; transform: none; }
  /* Round 12, item 7: at "View all races", once the user scrolls past it,
     the whole races block swipes RIGHT and out (JS adds .is-out via a
     dedicated observer, same pattern as the existing last-road-box one).
     Coordinator flag (calm-lock leak, 390x844): the observer itself is now
     also gated !isCalm() (main.js), but this rule's own !important would
     still win over calm's plain opacity:1/transform:none baseline even if
     the class were ever present, so it needs its own guard too. */
  html:not(.is-calm) .races-head.is-out, html:not(.is-calm) .race-tile.is-out {
    opacity: 0 !important; transform: translateX(2.25rem) !important;
    transition: opacity 450ms ease, transform 450ms ease !important;
  }
  .races-view-all { position: static; display: block; width: fit-content; margin: 1.5rem auto 0; transform: none; }
  .races-hint { display: none; }

  /* ---- Steps: not pinned. Heading, 2x2 compact tile grid, staggered reveal ---- */
  .section-services { height: auto; margin-top: 0; padding: 4rem 0; }
  /* Dylan round 6, item 4: 32px between the heading group and the grid. */
  .services-pin { position: static; height: auto; min-height: 0; overflow: visible; opacity: 1; transform: none; padding: 0 var(--page-pad); gap: 2rem; }
  .steps-head { opacity: 1; transform: none; }
  /* 24px between the eyebrow and the h2 (was the shared 1rem/16px rule). */
  .steps-head .eyebrow { margin-bottom: 1.5rem; }
  .steps-route { grid-template-columns: repeat(2, 1fr); gap: 0.75rem; align-items: stretch; }
  .steps-route::before { display: none; }
  .step-card { min-height: 0; height: 100%; padding: 1.1rem; }
  /* One-line description, clamped so it never wraps awkwardly into the next
     tile's space -- was fully hidden before; Dylan wants it visible. */
  .step-card p {
    display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
    margin-top: 0.4rem; font-size: 0.78rem; line-height: 1.3;
  }
  .step-card.phone-reveal {
    opacity: 0; transform: scale(0.94);
    transition: opacity 500ms ease, transform 500ms ease;
  }
  .step-card.phone-reveal.is-in { opacity: 1; transform: none; }
  .step-card:nth-child(1) { transition-delay: 0ms; }
  .step-card:nth-child(2) { transition-delay: 90ms; }
  .step-card:nth-child(3) { transition-delay: 180ms; }
  .step-card:nth-child(4) { transition-delay: 270ms; }

  /* ---- Road: not pinned. Heading, 4 boxes stacked, photo-on-top each ---- */
  /* Dylan round 7, item 6: overflow hidden + black so the pavement texture
     (and its new top/bottom mask below) stays inside the section, and the
     section's end dissolves to black for the Partners film to fade in from. */
  /* Round 12, item 9: overflow:hidden dropped on phone specifically -- it
     breaks position:sticky for the new .road-lock-inner wrapper below (an
     overflow:hidden ancestor becomes sticky's containing block and pins the
     element to ITS box instead of the viewport, confirmed via direct
     getBoundingClientRect sampling: the "stuck" element was moving 1:1 with
     scroll instead of staying put). It was only ever needed to clip
     .road-bg's desktop-only -12%/scale bleed (see .road-bg's own comment);
     the phone override two rules below already resets that to inset:0 with
     no transform, so there is nothing left to clip on phone. */
  .section-road { margin-top: 0; height: auto; padding: 0 0 4rem; overflow: visible; background: #000; }
  .road-pin { position: static; height: auto; min-height: 0; overflow: visible; opacity: 1; }
  .road-bg, .road-tint { opacity: 0.35; }
  .road-bg {
    inset: 0; transform: none;
    -webkit-mask-image: linear-gradient(to bottom, transparent, #000 10%, #000 62%, transparent);
    mask-image: linear-gradient(to bottom, transparent, #000 10%, #000 62%, transparent);
  }
  /* The painted lane lines are absolutely positioned against the old
     100vh pinned box; in the static stacked layout the center dashed line
     runs straight through the "Every detail earns its place." heading
     (review, frame ~7800). Hidden per spec's own "hide the lane lines" phone
     option -- pavement texture alone still carries the road motif. */
  .road-lines { display: none; }
  /* width:auto explicitly overrides the OLD 704px block's `width: min(100%,
     26rem)` (styles.css ~line 1127, same specificity, still matches here
     too) -- that fixed width plus this rule's own side margins pushed the
     heading 20px past the right edge (overlap.mjs CLIPPED-X, c-6900). */
  /* Round 7, item 6: the title was dim because a static-positioned head sits
     BELOW the absolutely positioned .road-tint overlay in paint order --
     position:relative + z-index lifts it above; full-opacity white with the
     gold span, 48px above / 32px below, zoom-in reveal (IO adds .is-in). */
  .road-head {
    position: relative; top: 0; left: 0; z-index: 2; max-width: none; width: auto; margin: 3rem var(--page-pad) 2rem;
    opacity: 1; transform: none;
  }
  .road-head h2 { color: #fff; }
  .road-head h2 span { color: var(--gold); }
  .road-head[data-phone-reveal] {
    opacity: 0; transform: scale(0.92); transform-origin: left center;
    transition: opacity 600ms ease, transform 600ms ease;
  }
  .road-head[data-phone-reveal].is-in { opacity: 1; transform: none; }
  .road-grid { position: static; display: flex; flex-direction: column; gap: 1.25rem; padding: 0 var(--page-pad); }
  /* Must match the OLD 704px phone block's `.road-box, .road-box.is-left,
     .road-box.is-right` compound selector shape (styles.css ~line 1115) or
     that rule's higher specificity wins for is-left/is-right boxes even
     inside this <=600px query, since both media queries match down here and
     a plain `.road-box` selector alone can't outrank a 2-class one no matter
     which comes later in the file (confirmed: all 4 boxes stacked at the
     same rect until this was added -- overlap.mjs OVERLAP h3 x h3 x h3 at
     390x844, c-6700). The 600-704px band keeps the old one-at-a-time
     sequential behaviour untouched (this block doesn't match there). */
  /* Dylan round 6, item 5: position:relative (not static) so the base
     .road-stop-photo rule (position:absolute; inset:0; opacity:0.55; its own
     ::after dark gradient) works as intended -- a faded background photo
     behind the text, not a stacked block above it. That base rule already
     does exactly what was asked (low-opacity photo + dark gradient for
     legibility); the old `static` here was the only thing turning it into a
     separate photo-on-top block by breaking its absolute positioning
     context. */
  .road-box, .road-box.is-left, .road-box.is-right {
    position: relative; inset: auto; width: auto; max-width: none; top: auto; left: auto; right: auto;
    opacity: 0; transform: translateY(1.25rem) scale(0.94) translateX(0); --box-side: 0;
    transition: opacity 500ms ease, transform 500ms ease;
  }
  .road-box.is-in, .road-box.is-left.is-in, .road-box.is-right.is-in { opacity: 1; transform: none; }
  /* Round 12, item 8: alternating swipe direction while still off-screen
     (01/03 from the left, 02/04 from the right) -- only the start transform
     changes; the settled .is-in state above is the same translateX(0) for
     every box, so this is purely an entrance direction, not a resting
     offset. */
  .road-box:nth-child(1), .road-box:nth-child(3) { transform: translateY(1.25rem) scale(0.94) translateX(-1.75rem); }
  .road-box:nth-child(2), .road-box:nth-child(4) { transform: translateY(1.25rem) scale(0.94) translateX(1.75rem); }
  /* Round 9, item 7: staggered pop-in, same pattern as .step-card. */
  .road-box:nth-child(1) { transition-delay: 0ms; }
  .road-box:nth-child(2) { transition-delay: 90ms; }
  .road-box:nth-child(3) { transition-delay: 180ms; }
  .road-box:nth-child(4) { transition-delay: 270ms; }
  /* Round 12, item 8: bottom-left "Keep scrolling" cue for the road section.
     Film-mode only (html:not(.is-calm)) -- calm mode's road section is
     calm's own separate treatment and this new in-flow element added real
     height there too (caught by the calm lock, a uniform +72px shift on
     everything from roadBox2 onward). The base .road-cue{display:none}
     (non-phone-scoped) rule keeps this fully hidden in calm/desktop/tablet. */
  html:not(.is-calm) .road-cue {
    display: flex; position: static; margin: 0 var(--page-pad) 1.5rem;
    align-items: center; gap: 0.5rem; color: rgba(255, 255, 255, 0.78);
    font-size: 0.62rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
    opacity: 0; transform: translateY(0.5rem); transition: opacity 600ms ease, transform 600ms ease;
  }
  .road-cue.is-in { opacity: 1; transform: none; }
  .road-cue svg { width: 0.85rem; height: 0.85rem; flex: none; fill: none; stroke: var(--gold); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; animation: cueBob 1.8s ease-in-out infinite; }
  /* Round 9, item 7: the last box fades back out once it scrolls past (JS
     adds .is-out via a dedicated observer in main.js), so the road section
     ends on black for the Partners film to fade in from, instead of the
     last box just vanishing off-screen mid-opacity. */
  .road-box.is-out { opacity: 0 !important; transition: opacity 700ms ease; }
  .road-stop-body { padding: 1.5rem 1.25rem 1.75rem; }
  .road-stop-body li:nth-child(n + 5) { display: none; }

  /* ---- Round 12, item 9 (revised): real lock via native CSS sticky ---- */
  /* main.js wraps #roadBox3 + #serviceMarquee into this pair, phone-only,
     never in calm. .road-lock-wrap is a tall runway (~190svh -- "a scroll
     or two" of hold); .road-lock-inner sticks near the header while the
     page scrolls through that runway, keeping both pieces on screen
     together, exactly the "lock" the coordinator asked for. updateRoadLock()
     (main.js) turns scroll progress through the runway into --lock-box-out /
     --lock-marquee-out, consumed below. */
  .road-lock-wrap { position: relative; height: 230svh; }
  .road-lock-inner {
    position: sticky; top: calc(var(--rr-nav-h, 66px) + 0.5rem);
    display: flex; flex-direction: column; gap: 1.25rem;
    /* Reviewer fix: before the pin fully engages (scrolling into view from
       below), box 04 and the marquee pass through the exact viewport band
       the fixed chat bubble occupies (bottom-right) -- measured overlap
       ~27x9px, sustained across ~300px of scroll (8300-8600 at 390x844),
       not a single frame. Reserve the chat button's footprint (24px inset +
       52px + a little breathing room) on the right the whole time, since
       that's cheaper and safer than trying to time-box it to only the
       pre-stick phase. */
    padding-right: 4.5rem; box-sizing: border-box;
  }
  /* Box 04 stays fully visible (its own existing data-phone-reveal entrance
     still handles the pop-in) through the first third of the runway, then
     fades/slides up and out while still stuck at the top -- an ID+class
     selector so it outranks the plain `.road-box.is-in{opacity:1}` rule.
     Coordinator flag (calm-lock leak, 390x844): roadBox3 never moves or gets
     the lock wrapper in calm (JS gate excludes calm), but this ID selector
     still matched it wherever it sits, and `translateY(calc(0 * -1.5rem))`
     computes to `translateY(0px)` -- textually different from calm's own
     `transform:none` baseline even though visually identical (same
     "matrix()/0px vs none" quirk as the earlier load-animation leaks).
     html:not(.is-calm) makes this rule not match in calm at all, falling
     back to the plain `.road-box.is-in{transform:none}` rule instead. */
  html:not(.is-calm) #roadBox3.is-in {
    opacity: calc(1 - var(--lock-box-out, 0));
    transform: translateY(calc(var(--lock-box-out, 0) * -1.5rem));
  }
  /* The showcase holds alone once box 04 is gone, then zooms inward and
     fades slowly (a wide 34%-of-runway span, per "lingers... slowly"),
     handing off to the foot video fading in behind it (A2's own Partners
     entrance, untouched) as the lock releases. Never matches in calm
     anyway (the .is-road-marquee class is only added by the calm-gated JS
     move), but guarded for consistency with the rule above. */
  html:not(.is-calm) #serviceMarquee.is-road-marquee {
    margin: 0.5rem 0 0; padding: 0;
    opacity: calc(1 - var(--lock-marquee-out, 0));
    transform: scale(calc(1 - (var(--lock-marquee-out, 0) * 0.12)));
  }
}

/* ===== CALM (Skip animation) =====
   Round 10. Every rule below is `.is-calm`-scoped and this is the only block
   any of it lives in -- see dylan-r10-calm.md. Two real JS bugs (root-caused
   and fixed in main.js's new syncCalmLayout()) sat behind three of these
   twelve items and are noted where they matter; the rest is CSS-only, either
   fixing a layout gap the spec called out by measurement or adding a light
   scroll reveal on top of the `[data-phone-reveal]` + `.is-in` observer that
   already runs at every width (main.js's phoneRevealObserver -- it isn't
   phone-only despite the name, it just wasn't used for desktop calm yet).
   Movement (transform) is gated behind `prefers-reduced-motion: no-preference`
   throughout, per spec: OS-level reduced motion keeps opacity-only fades even
   though `.is-calm` is the same class in both cases; the button-driven case
   is the only one allowed the extra transform. */

/* Item 1: hero copy was bottom-anchored (base .hero-content: position:
   absolute; bottom: clamp(...)), landing crunched over the painted start
   line with the eyebrow half into the runner's leg (matches images/6.webp).
   Moved to the top half, cleared under the fixed header, with more room
   between eyebrow/heading/paragraph/buttons than the film-mode spacing.
   Fade/rise on load via .is-calm-in, added one frame after paint by
   syncCalmLayout() in main.js (both at init and on every mode switch) so the
   opacity:0 starting state actually gets painted before it transitions. */
.is-calm .hero-opening {
  opacity: 0;
  transition: opacity 700ms ease;
}
/* The top-anchor repositioning is desktop/tablet only (min-width guard, not
   a max-width override+cancel): phone already has its own top-anchored
   calm/film-shared layout (search .hero-opening in the phone block) that
   this must not fight over `position` -- an earlier version of this rule
   forced `position: static` back at phone widths to cancel the desktop
   `top`, which dropped .hero-opening out of `.hero-content`'s z-index:5
   stacking context (z-index has no effect on a static element) and let the
   absolutely-positioned .hero-scrim paint over it, hiding the hero buttons
   behind it (found via clickable.mjs at 390x844). Scoping the whole
   position/top change to min-width from the start avoids that. */
@media (min-width: 44.01rem) {
  .is-calm .hero-opening { top: calc(var(--rr-nav-h, 5rem) + 2.5rem); bottom: auto; }
}
.is-calm .hero-opening.is-calm-in { opacity: 1; }
.is-calm .hero-opening .eyebrow { margin-bottom: 1.75rem; }
.is-calm .hero-opening .hero-lead { margin-top: 1.75rem; }
.is-calm .hero-opening .button-row { margin-top: 2.25rem; }
@media (prefers-reduced-motion: no-preference) {
  .is-calm .hero-opening { transform: translateY(1rem); transition: opacity 700ms ease, transform 700ms ease; }
  .is-calm .hero-opening.is-calm-in { transform: none; }
}

/* Item 3: the three feature cards (#heroCards) are relocated by
   syncCalmLayout() in main.js out of the pinned, overflow:hidden hero stage
   -- a plain 100vh box in calm, so anything inside it past that height was
   clipped -- to sit as their own flowing section directly after <section
   class="hero">. This is the layout for that relocated position; the film-
   mode absolute-overlay rules for #heroCards/.hero-card (styles.css ~257)
   are untouched and simply don't apply once it's out of .hero-stage.
   .hero-card's own opacity: var(--card-alpha, 0) also defaults to 0 when
   updateHero() never runs (calm) -- reset to 1 here, then to the reveal
   below. */
.is-calm #heroCards.hero-cards {
  position: static; inset: auto; z-index: auto; pointer-events: auto;
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem;
  width: 100%; max-width: 84rem; margin: 0 auto;
  padding: 4rem var(--page-pad) 5rem; background: var(--black);
}
.is-calm #heroCards.hero-cards .hero-card {
  width: 100%; opacity: 0; transform: translateY(1rem);
  transition: opacity 500ms ease, transform 500ms ease, border-color 200ms ease, box-shadow 200ms ease;
}
.is-calm #heroCards.hero-cards .hero-card:nth-child(1) { transition-delay: 0ms; }
.is-calm #heroCards.hero-cards .hero-card:nth-child(2) { transition-delay: 100ms; }
.is-calm #heroCards.hero-cards .hero-card:nth-child(3) { transition-delay: 200ms; }
.is-calm #heroCards.hero-cards.is-calm-in .hero-card { opacity: 1; transform: none; }
@media (max-width: 44rem) {
  .is-calm #heroCards.hero-cards { grid-template-columns: 1fr; padding: 3rem var(--page-pad) 3.5rem; gap: 1rem; }
}

/* Item 5: road boxes 3/4 sat flush against Partners (0px measured, both
   .is-calm .section-road and .is-calm .partner reset their film-mode
   margin-top:-100vh hand-off to 0 with nothing added back). ~120px of real
   room before the next section, per spec. */
.is-calm .road-pin { padding-bottom: calc(5rem + 120px); }

/* Item 6 (bug): the buttons/bullets weren't reachable because updateFoot()
   in main.js -- which sets #partnerCopy/#partnerImage .inert while scrolling
   in film mode -- bails out immediately on isCalm() and never runs again to
   clear it, so a film-mode scroll that stopped mid-fade left `inert` stuck
   true. Fixed in main.js (syncCalmLayout(), called at init and on every
   mode switch) -- CSS here is only the "photo pops in, then title, then
   bullets one at a time" light reveal on top of that fix. The bullet hover
   itself (text to gold, small shift) already exists unscoped at
   `.partner-list li:hover` (~line 775) and needed no calm-specific rule --
   it was simply unreachable behind the same inert bug. */
.is-calm .partner-image[data-phone-reveal] { opacity: 0; transition: opacity 550ms ease; }
.is-calm .partner-image[data-phone-reveal].is-in { opacity: 1; }
.is-calm .partner-heading[data-phone-reveal] { opacity: 0; transition: opacity 450ms ease 150ms; }
.is-calm .partner-heading[data-phone-reveal].is-in { opacity: 1; }
.is-calm .partner-list li[data-phone-reveal] { opacity: 0; transition: opacity 400ms ease; }
.is-calm .partner-list li[data-phone-reveal].is-in { opacity: 1; }
.is-calm .partner-list li[data-phone-reveal]:nth-child(1) { transition-delay: 300ms; }
.is-calm .partner-list li[data-phone-reveal]:nth-child(2) { transition-delay: 420ms; }
.is-calm .partner-list li[data-phone-reveal]:nth-child(3) { transition-delay: 540ms; }
@media (prefers-reduced-motion: no-preference) {
  .is-calm .partner-image[data-phone-reveal] { transform: scale(0.94); transition: opacity 550ms ease, transform 550ms ease; }
  .is-calm .partner-image[data-phone-reveal].is-in { transform: none; }
  .is-calm .partner-list li[data-phone-reveal] { transform: translateX(1rem); transition: opacity 400ms ease, transform 400ms ease; }
  .is-calm .partner-list li[data-phone-reveal].is-in { transform: none; }
}

/* Item 7 (bug): the universal `.is-calm * { animation-duration: 0.01ms
   !important }` a few sections up (written for reduced-motion parity) also
   silences the marquee's own CSS @keyframes scroll -- spec item 7 wants it
   frozen only under real OS-level reduced motion, not button-driven calm.
   Restores it here (matching specificity/!important, later in the
   cascade), then re-freezes it specifically under the media query. */
.is-calm .marquee-track { animation-duration: 40s !important; animation-iteration-count: infinite !important; }
@media (prefers-reduced-motion: reduce) {
  .is-calm .marquee-track { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
}

/* Item 4: steps get the same staggered reveal already built for phone
   (.step-card.phone-reveal + .is-in, styles.css phone block), applied here
   for calm at every width via the always-on observer. */
.is-calm .step-card.phone-reveal { opacity: 0; transition: opacity 500ms ease; }
.is-calm .step-card.phone-reveal.is-in { opacity: 1; }
.is-calm .step-card.phone-reveal:nth-child(1) { transition-delay: 0ms; }
.is-calm .step-card.phone-reveal:nth-child(2) { transition-delay: 90ms; }
.is-calm .step-card.phone-reveal:nth-child(3) { transition-delay: 180ms; }
.is-calm .step-card.phone-reveal:nth-child(4) { transition-delay: 270ms; }
@media (prefers-reduced-motion: no-preference) {
  .is-calm .step-card.phone-reveal { transform: translateY(1rem) scale(0.97); transition: opacity 500ms ease, transform 500ms ease; }
  .is-calm .step-card.phone-reveal.is-in { transform: none; }
}

/* Item 5 continued: road boxes scroll in one at a time (already tagged
   data-phone-reveal, same always-on observer), overriding the earlier
   `.is-calm .road-box { opacity: 1 }` (styles.css ~1399) by attribute
   selector specificity. */
.is-calm .road-box[data-phone-reveal] { opacity: 0; transition: opacity 500ms ease; }
.is-calm .road-box[data-phone-reveal].is-in { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
  .is-calm .road-box[data-phone-reveal] { transform: translateY(1.25rem) scale(0.96); transition: opacity 500ms ease, transform 500ms ease; }
  .is-calm .road-box[data-phone-reveal].is-in { transform: none; }
}

/* Item 10: a light swipe into the contact form. .contact-form already
   carries the generic `.reveal` class/observer; the generic
   `.is-calm .reveal { transform: none !important }` rule a few sections up
   is more specific here (.contact-form.reveal, 2 classes vs 1) so this wins
   without touching that rule, which stays correct for every other .reveal
   element in calm. */
@media (prefers-reduced-motion: no-preference) {
  .is-calm .contact-form.reveal { transform: translateX(2.5rem) !important; transition: opacity 500ms ease, transform 500ms ease !important; }
  .is-calm .contact-form.reveal.is-visible { transform: none !important; }
}

/* ===== Round 11 (calm items 11-14) ===== */

/* Item 11: the hero paragraph reads .hero-lead's own base color: var(--muted)
   (a dim gray, meant for the paragraph sitting on top of the moving film
   where full white competed with the runner's face) -- against calm's flat
   dark background it is hard to read. White here matches the eyebrow/
   heading/buttons, which are already full white or gold. */
.is-calm .hero-opening .hero-lead { color: var(--white); }

/* Item 12 (bug, EMPTY feature cards on desktop calm): updateHero()'s FLIP
   logic sets an inline `--card-content-alpha` on each .hero-card while
   scrolling in film mode and bails on isCalm() without ever clearing it
   (main.js) -- `.hero-card-photo, .hero-card-body { opacity: var(
   --card-content-alpha, 1) }` (styles.css ~296) then reads that stuck value
   forever once calm is active, so the card's own border/background still
   render (an empty box) but the photo/number/title/desc/button do not. The
   inline var is now cleared in main.js's syncCalmLayout() (same fix shape
   as items 6/11 last round); this is the belt-and-suspenders CSS half so
   the fix does not depend on that JS having already run first. */
.is-calm #heroCards.hero-cards .hero-card-photo,
.is-calm #heroCards.hero-cards .hero-card-body { opacity: 1; }

/* Item 13: races cards and steps cards pop in staggered as the section
   enters, same `[data-phone-reveal]` + `.is-in` pattern already used for
   steps/road/partner above (the observer is global, not phone-only). Steps
   already got this in round 10 (item 4); this adds races. .race-tile has no
   base opacity rule of its own (film mode drives the pinned track
   sideways, not per-tile fades), so nothing to override here beyond adding
   the entrance. */
.is-calm .race-tile[data-phone-reveal] { opacity: 0; transition: opacity 450ms ease; }
.is-calm .race-tile[data-phone-reveal].is-in { opacity: 1; }
.is-calm .race-tile[data-phone-reveal]:nth-child(1) { transition-delay: 0ms; }
.is-calm .race-tile[data-phone-reveal]:nth-child(2) { transition-delay: 80ms; }
.is-calm .race-tile[data-phone-reveal]:nth-child(3) { transition-delay: 160ms; }
.is-calm .race-tile[data-phone-reveal]:nth-child(4) { transition-delay: 240ms; }
.is-calm .race-tile[data-phone-reveal]:nth-child(5) { transition-delay: 320ms; }
@media (prefers-reduced-motion: no-preference) {
  .is-calm .race-tile[data-phone-reveal] { transform: translateY(1rem) scale(0.97); transition: opacity 450ms ease, transform 450ms ease; }
  .is-calm .race-tile[data-phone-reveal].is-in { transform: none; }
}

/* Item 14: "Partner with us." above the bullets (in the same right-hand
   column as the bullets/buttons, photo on the left spanning both rows) --
   the base (film-mode) `.partner-grid` grid-template-areas is
   "title title" / "photo details" (title spans the full width, above
   everything). A2 is reworking that layout for film mode in the same
   selector this round; this is calm's own copy of the target shape so it
   does not depend on which version of that rule lands there. Named areas
   auto-span contiguous same-named cells, so repeating "photo" both rows
   spans .partner-image (grid-area:photo, already set) down the full left
   column without new syntax. Bullets already pop in one by one (round 10);
   this adds the CTA button-row zooming in after them. */
.is-calm .partner-grid {
  grid-template-rows: auto minmax(0, 1fr);
  grid-template-areas: "photo title" "photo details";
}
.is-calm #partnerDetails .button-row[data-phone-reveal] {
  opacity: 0; transition: opacity 400ms ease 650ms;
}
.is-calm #partnerDetails .button-row[data-phone-reveal].is-in { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
  .is-calm #partnerDetails .button-row[data-phone-reveal] {
    transform: scale(0.82); transition: opacity 400ms ease 650ms, transform 400ms ease 650ms;
  }
  .is-calm #partnerDetails .button-row[data-phone-reveal].is-in { transform: none; }
}


/* Phone-only element (index.html); never rendered above 600px or in calm. */
.keep-scroll-cue { display: none; }

/* ===== Round 1 mobile pass (<=600px, film mode only unless noted) =====
   Everything here is inside the phone query so desktop is untouched. Scoped
   html:not(.is-calm) wherever the element also renders in calm. ===== */
@media (max-width: 600px) {
  /* Item 3: one global "Keep scrolling" cue, fixed bottom-left, gone at the end of the page (main.js toggles .is-end).
     It replaces the per-section cues, which are hidden so two never show at once. */
  html:not(.is-calm) .keep-scroll-cue {
    display: flex; position: fixed; left: var(--page-pad); bottom: 12px; z-index: 250; align-items: center; gap: 0.45rem;
    padding: 0.28rem 0.7rem 0.28rem 0.5rem; border-radius: 999px; background: rgba(0, 0, 0, 0.66);
    color: var(--white); font-size: 0.62rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
    pointer-events: none; transition: opacity 350ms ease, visibility 350ms;
  }
  html:not(.is-calm) .keep-scroll-cue.is-end { opacity: 0; visibility: hidden; }
  .keep-scroll-cue svg { width: 0.85rem; height: 0.85rem; flex: none; fill: none; stroke: var(--gold); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; animation: cueBob 1.8s ease-in-out infinite; }
  html:not(.is-calm) .hero-cue, html:not(.is-calm) .scroll-cue, html:not(.is-calm) .road-cue { display: none !important; }

  /* Item 2: the two hero buttons are on the first screen and rise in one after the other. */
  html:not(.is-calm) .hero-opening .button-row { animation: none; }
  html:not(.is-calm) .hero-opening .button-row .button { animation: heroButtonsPop 520ms cubic-bezier(0.2, 0.7, 0.2, 1) 1350ms both; }
  html:not(.is-calm) .hero-opening .button-row .button:nth-child(2) { animation-delay: 1650ms; }

  /* Item 4: shorter feature cards, more photo, dark gradient so the title reads. Heading, three cards and the
     bottom cue share one screen; flex centering leaves equal air above and below. */
  html:not(.is-calm) .hero-cards { gap: 0.75rem; padding: var(--rr-nav-h, 66px) var(--page-pad) 3.25rem; } /* bottom pad = room for the Keep scrolling pill, so the block centers between header and pill (Dylan 2026-10-03) */
  html:not(.is-calm) .hero-cards::before { margin: 0 0 0.05rem; }
  html:not(.is-calm) .hero-card {
    flex: 0 0 auto; height: clamp(8.25rem, calc((100svh - 19.75rem) / 3), 13rem); overflow: hidden; background: #000;
  }
  html:not(.is-calm) .hero-card-photo { position: absolute; inset: 0; max-height: none; border-radius: inherit; }
  html:not(.is-calm) .hero-card-photo::after {
    content: ""; position: absolute; inset: 0; border-radius: inherit;
    background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.55) 42%, rgba(0, 0, 0, 0.18) 100%);
  }
  html:not(.is-calm) .hero-card-body { margin-top: auto; padding: 0.5rem 1.1rem 0.85rem; gap: 0.3rem; }

  /* Item 5/6: services (steps) enters on its own: heading swipes in from the left, the four steps zoom in one by
     one, then the CTA. Triggered by its own observer (main.js) once the block is actually in view. */
  html:not(.is-calm) .steps-head[data-phone-reveal] {
    opacity: 0; transform: translateX(-3rem); transition: opacity 700ms ease, transform 850ms cubic-bezier(0.2, 0.7, 0.2, 1);
  }
  html:not(.is-calm) .steps-head[data-phone-reveal].is-in { opacity: 1; transform: none; }
  html:not(.is-calm) .step-card.phone-reveal {
    opacity: 0; transform: translateX(-2.5rem) scale(0.8);
    transition: opacity 600ms ease, transform 750ms cubic-bezier(0.2, 0.7, 0.2, 1);
  }
  html:not(.is-calm) .step-card.phone-reveal.is-in { opacity: 1; transform: none; }
  html:not(.is-calm) .step-card:nth-child(1) { transition-delay: 220ms; }
  html:not(.is-calm) .step-card:nth-child(2) { transition-delay: 360ms; }
  html:not(.is-calm) .step-card:nth-child(3) { transition-delay: 500ms; }
  html:not(.is-calm) .step-card:nth-child(4) { transition-delay: 640ms; }
  html:not(.is-calm) .steps-cta {
    opacity: 0; transform: translateY(1rem) scale(0.92); transition: opacity 600ms ease 820ms, transform 700ms cubic-bezier(0.2, 0.7, 0.2, 1) 820ms;
  }
  html:not(.is-calm) .steps-cta.is-in { opacity: 1; transform: none; }
  .steps-cta .button { width: 100%; }

  /* Item 7: all four road boxes the same width and height; the last one gets its own photo. */
  html:not(.is-calm) .road-box { min-height: 24rem; display: flex; flex-direction: column; }
  html:not(.is-calm) .road-box .road-stop-body { flex: 1 1 auto; height: auto; }
  html:not(.is-calm) .road-box .road-stop-body > h3 { margin-bottom: 0.85rem; }
  #roadBox3 { --photo: url("assets/real/trail-runners.jpg") !important; --photo-pos: 50% 40% !important; }
  .road-lock-inner {
    padding-right: 0; justify-content: center; min-height: calc(100svh - var(--rr-nav-h, 66px) - 1rem);
  }
  html:not(.is-calm) #serviceMarquee.is-road-marquee {
    transform: translateY(var(--lock-shift, 0px)) scale(calc(1 - (var(--lock-marquee-out, 0) * 0.12)));
  }

  /* Item 8a: foot film zooms in from the middle and settles at the top; the Josh photo pops in with equal margins. */
  html:not(.is-calm) .partner { z-index: 5; }
  html:not(.is-calm) .partner-stage { transform-origin: 50% 40%; transform: scale(var(--foot-zoom, 1)); opacity: var(--foot-in, 1); }
  .partner-media-sticky #partnerImage {
    top: calc(var(--rr-nav-h, 66px) + 20px); bottom: 96px; left: 20px; right: 20px; height: auto; max-height: none; width: auto; aspect-ratio: auto; align-self: stretch;
    transform: scale(calc(0.9 + var(--phone-photo-in, 0) * 0.1)); transition: none;
  }
  .partner-media-sticky #partnerImage img { object-position: 50% 30%; }

  /* Item 8b: founders is a short pin on phone; items pop in one by one (class on .about-pin), then swipe outward. */
  html:not(.is-calm) .about { height: 240svh; }
  html:not(.is-calm) .about-pin {
    position: sticky; top: 0; height: 100svh; min-height: 0; display: flex; align-items: center;
    padding: calc(var(--rr-nav-h, 66px) + 0.5rem) 0 1rem; overflow: hidden;
  }
  html:not(.is-calm) .about-pin .a2-pr { transition: opacity 900ms cubic-bezier(0.16, 1, 0.3, 1), transform 1100ms cubic-bezier(0.16, 1, 0.3, 1); transition-delay: var(--ph-d, 0ms); }
  html:not(.is-calm) #foundersPhoto { --ph-from: translateY(2.5rem) scale(0.88) !important; --ph-d: 0ms !important; }
  html:not(.is-calm) #foundersHeading { --ph-from: translateX(-3rem) !important; --ph-d: 300ms !important; }
  html:not(.is-calm) #foundersText { --ph-from: translateY(1.75rem) !important; --ph-d: 600ms !important; }
  html:not(.is-calm) #foundersMeta { --ph-from: translateY(1.25rem) scale(0.85) !important; --ph-d: 900ms !important; }
  html:not(.is-calm) .about-pin.is-in .a2-pr { opacity: 1; transform: none; }

  /* Item 8c: the finish stage locks in place and fades up; results block is one clean left-aligned stack. */
  html:not(.is-calm) .finish-film { z-index: 4; height: 580svh; }
  /* Title overlays the top of the image instead of reserving its own empty row above it. */
  html:not(.is-calm) .finish-cta-head { grid-area: 2 / 1; align-self: start; z-index: 2; padding: 14px var(--page-pad) 0; text-shadow: 0 2px 12px rgba(0, 0, 0, 0.75); }
  html:not(.is-calm) .finish-cta-badge { top: calc(var(--rr-nav-h, 66px) + 78px); }
  html:not(.is-calm) .results-overlay .results-panel > a.button { transition-delay: 120ms; }
  html:not(.is-calm) .results-overlay { padding: 1.25rem var(--page-pad) 0; }
  html:not(.is-calm) .results-overlay .results-panel { gap: 1rem; }
  html:not(.is-calm) .results-overlay .results-panel .eyebrow,
  html:not(.is-calm) .results-overlay .results-panel h2 { position: static; top: auto; left: auto; max-width: none; }
  html:not(.is-calm) .results-overlay .results-panel h2 { font-size: clamp(1.6rem, 7.2vw, 1.95rem); line-height: 1.1; }
  html:not(.is-calm) .results-overlay .stat-row { margin: 0.25rem 0 0.5rem; gap: 0.75rem; }
  html:not(.is-calm) .results-overlay .stat { padding-left: 0.75rem; }
  html:not(.is-calm) .results-overlay .results-panel > a.button { align-self: stretch; width: auto; }

  /* Item 8e: community pins on the photo, its content and photo fade out slowly, the blog fades in as it scrolls up and then holds. */
  html:not(.is-calm) .community { height: 300svh; }
  html:not(.is-calm) .community-pin { position: sticky; top: 0; height: 100svh; overflow: hidden; padding-top: var(--rr-nav-h, 66px); }
  html:not(.is-calm) .community-video { position: relative; top: auto; }
  html:not(.is-calm) .community-caption { top: calc(var(--rr-nav-h, 66px) + 14px); }
  html:not(.is-calm) .community-freetag { bottom: auto; top: calc(var(--rr-nav-h, 66px) + 75vw - 1.7rem); }
  html:not(.is-calm) .blog { margin-top: -100svh; height: 240svh; padding: 0; background: transparent; position: relative; z-index: 6; }
  html:not(.is-calm) .blog > .section-shell {
    position: sticky; top: 0; height: 100svh; display: flex; flex-direction: column; justify-content: center;
    padding-top: calc(var(--rr-nav-h, 66px) + 0.5rem); padding-bottom: 1rem; box-sizing: border-box;
    opacity: var(--blog-in, 0); transform: translateY(calc((1 - var(--blog-in, 0)) * 2.5rem)); pointer-events: var(--blog-pe, none);
  }
  html:not(.is-calm) .blog .blog-head { margin-bottom: 1rem; }
  .blog-grid { scroll-padding-left: var(--page-pad); }
  html:not(.is-calm) .blog-foot { margin-top: 1rem; }
}
@media (max-width: 600px) and (max-height: 760px) {
  html:not(.is-calm) .community-video { aspect-ratio: 16 / 9; }
  html:not(.is-calm) .community-freetag { top: calc(var(--rr-nav-h, 66px) + 56.25vw - 1.7rem); }
  html:not(.is-calm) .blog-card-img { height: 8rem; }
  html:not(.is-calm) .blog-card-excerpt { display: none; }
  html:not(.is-calm) .road-box { min-height: 24rem; }
}

/* ===== Round 3 desktop pass (>=601px only; phone is untouched) =====
   Base state of every new element is hidden, so phone and calm layouts never
   see them. ===== */
.hero-cards-cta, .finish-cta-cue, .contact-roo, .copy-long { display: none; }
@media (min-width: 601px) {
  /* 1. "Finish" moves left of the chat launcher. */
  .race-line { right: calc(var(--page-pad) + 5.25rem); }

  /* 6. Road keeps its pin for an extra 100vh so the street can crossfade into the foot film (see updateRoad). */
  html:not(.is-calm) .section-road { height: 560vh; }

  /* 3. Hero cards: portrait photo on the card that was landscape, gold buttons with a hover animation. */
  /* Card 2 shows the timing-mat still (Josh, 2026-10-03: the packet-pickup photo read as pickup, not timing). Keep the stride on the mat in frame. */
  .hero-card:nth-child(2) .hero-card-photo { background-position: 52% 60%; }
  .hero-card-btn {
    border-color: var(--gold); background: var(--gold); color: var(--black);
    transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1), background-color 200ms ease, box-shadow 220ms ease, border-color 200ms ease, color 200ms ease;
  }
  .hero-card:hover .hero-card-btn, .hero-card-btn:hover {
    background: var(--gold-hover); border-color: var(--gold-hover); color: var(--black);
    transform: translateY(-2px) scale(1.05); box-shadow: 0 8px 22px rgba(212, 167, 44, 0.38);
  }

  /* 4. One premium gold hover for every feature box: gold border + ring, soft glow, gold tint, 6px lift.
     "translate" (not "transform") so it composes with the scroll-driven transforms already on these boxes. */
  .hero-card, .step-card, .road-box, .race-tile {
    transition: translate 300ms cubic-bezier(0.2, 0.7, 0.2, 1), border-color 260ms ease, box-shadow 300ms ease, background-color 300ms ease;
  }
  .hero-card:hover, .step-card:hover, .road-box:hover, .race-tile:hover {
    translate: 0 -6px; border-color: var(--gold);
    box-shadow: 0 0 0 1px var(--gold) inset, 0 18px 40px rgba(0, 0, 0, 0.45), 0 0 34px rgba(212, 167, 44, 0.2);
  }
  .race-tile:hover { transform: none; }
  .step-card:hover {
    transform: translateY(calc((1 - var(--step-in, 0)) * 2rem)) scale(calc(0.94 + (var(--step-in, 0) * 0.06))) translateX(calc(var(--step-exit, 0) * -4rem));
    background-image: linear-gradient(rgba(212, 167, 44, 0.1), rgba(212, 167, 44, 0.1));
  }
  .hero-card:hover { background-image: linear-gradient(rgba(212, 167, 44, 0.08), rgba(212, 167, 44, 0.08)); }
  .race-tile:hover::after { background: linear-gradient(180deg, rgba(212, 167, 44, 0.16), rgba(0, 0, 0, 0.9) 72%); }
  .road-box:hover .road-stop-photo::after {
    background: linear-gradient(rgba(212, 167, 44, 0.12), rgba(212, 167, 44, 0.12)),
      linear-gradient(100deg, rgba(10, 10, 10, 0.4), rgba(10, 10, 10, 0.72) 55%, rgba(10, 10, 10, 0.88)),
      linear-gradient(180deg, rgba(10, 10, 10, 0.3), transparent 30%, transparent 55%, rgba(10, 10, 10, 0.95));
  }

  /* 5. CTA under the hero cards, on screen with them (alpha from updateHero). */
  html:not(.is-calm) .hero-cards-cta {
    display: inline-flex; position: absolute; left: 50%; bottom: 7.4rem; z-index: 2;
    opacity: var(--cards-cta, 0); transform: translateX(-50%) translateY(calc((1 - var(--cards-cta, 0)) * 1rem));
  }
  html:not(.is-calm) .hero-cards:not([aria-hidden="false"]) .hero-cards-cta { pointer-events: none; }

  /* 7. Finish call: centered "Keep scrolling" arrow under the buttons. */
  html:not(.is-calm) .finish-cta-cue {
    display: flex; flex-direction: column; align-items: center; gap: 0.35rem; margin-top: 2.25rem;
    color: rgba(255, 255, 255, 0.8); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
  }
  .finish-cta-cue svg, html:not(.is-calm) .keep-scroll-cue svg {
    width: 1.2rem; height: 1.2rem; fill: none; stroke: var(--gold); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; animation: cueBob 1.8s ease-in-out infinite;
  }
  /* ...and in place of the kangaroo bar from there on, a bottom-centre "Keep scrolling" (toggled in updateKeepCue). */
  html:not(.is-calm) .keep-scroll-cue {
    display: flex; position: fixed; left: 50%; bottom: 1.1rem; z-index: 70; align-items: center; gap: 0.6rem;
    color: rgba(255, 255, 255, 0.8); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
    padding: 0.35rem 0.9rem; border-radius: 999px; background: rgba(0, 0, 0, 0.62);
    opacity: 0; transform: translateX(-50%); pointer-events: none; transition: opacity 500ms ease;
  }
  html:not(.is-calm) .keep-scroll-cue.is-desk-on { opacity: 1; }

  /* 8. Contact: the jumping kangaroo above the title, and the longer line under it. */
  .contact-roo { display: block; width: 3.75rem; height: auto; margin-bottom: 1.1rem; }
  .copy-short { display: none; }
  .copy-long { display: inline; }

  /* 9. "Free for every runner": a little bigger, italic, same place. */
  html:not(.is-calm) .community-freetag { font-size: 1.3rem; font-style: italic; letter-spacing: 0.1em; }
}

/* ===== Phone pass, Dylan 2026-10-03 (reports/2026-10-03--mobile-pass-checklist.md). Phone + film mode only. ===== */
@media (max-width: 600px) {
  /* Item 12: "Free for every runner" already heads the caption; the second copy on the photo corner goes. */
  html:not(.is-calm) .community-freetag { display: none; }
  /* Item 11: the kangaroo badge landed on the painted FINISH LINE. It comes off the image; the full RUNROO logo
     sits in the open black below "Ask about timing" instead, fading in with the buttons (it is part of that row). */
  html:not(.is-calm) .finish-cta-badge { display: none; }
  html:not(.is-calm) .finish-cta .button-row::after {
    content: ""; display: block; height: 3.25rem; margin-top: 2.5rem;
    background: url("logos/svg/runroo-primary-horizontal-on-dark.svg") center / contain no-repeat;
  }
  /* Item 8: the service strip pops in after "You run the mission" (--lock-marquee-in), glides to centre and holds,
     then scrolls up and out (--lock-marquee-out) as the foot film arrives, instead of zooming/fading in place. */
  html:not(.is-calm) #serviceMarquee.is-road-marquee {
    opacity: calc(var(--lock-marquee-in, 0) * (1 - (var(--lock-marquee-out, 0) * 0.55)));
    transform: translateY(calc(var(--lock-shift, 0px) - (var(--lock-marquee-out, 0) * 20svh))) scale(calc(0.9 + (var(--lock-marquee-in, 0) * 0.1)));
  }
  /* Item 6: "One crew, one course" cards pop in one at a time, slower. */
  html:not(.is-calm) .step-card.phone-reveal { transition: opacity 800ms ease, transform 950ms cubic-bezier(0.2, 0.7, 0.2, 1); }
  html:not(.is-calm) .step-card:nth-child(1) { transition-delay: 300ms; }
  html:not(.is-calm) .step-card:nth-child(2) { transition-delay: 750ms; }
  html:not(.is-calm) .step-card:nth-child(3) { transition-delay: 1200ms; }
  html:not(.is-calm) .step-card:nth-child(4) { transition-delay: 1650ms; }
  html:not(.is-calm) .steps-cta { transition: opacity 700ms ease 2150ms, transform 800ms cubic-bezier(0.2, 0.7, 0.2, 1) 2150ms; }
  /* Item 7: "What we do" boxes pop in one by one as each reaches the screen (main.js adds .is-in on scroll), slower.
     Box 04 keeps its own fast transition: its opacity is scroll-scrubbed by the road lock afterwards. */
  html:not(.is-calm) .road-box:not(#roadBox3) { transition: opacity 850ms ease, transform 950ms cubic-bezier(0.2, 0.7, 0.2, 1); }
  /* Item 9: the Josh photo stays inside the foot film window (55svh under the header), covering the pavement,
     instead of filling the whole screen and spilling into the black band below. */
  html:not(.is-calm) .partner-media-sticky #partnerImage {
    top: calc(var(--rr-nav-h, 66px) + 14px); bottom: auto; height: calc(55svh - 28px); left: 18px; right: 18px;
  }
  /* Item 10: Partner text block keeps clear of the Keep scrolling pill on shorter phones (button sat under it at 660). */
  html:not(.is-calm) #partnerMediaSticky #partnerCopy { padding-top: 8px; padding-bottom: 46px; }
  html:not(.is-calm) #partnerMediaSticky #partnerCopy .partner-list li { padding-block: 4px; }
  html:not(.is-calm) #partnerMediaSticky #partnerCopy .button-row .button { min-height: 2.4rem; padding-block: 0.45rem; }
  /* Item 10: after the hold and the photo/foot fade, "Partner with us" swipes away left (main.js scrubs --text-out). */
  html:not(.is-calm) #partnerMediaSticky #partnerCopy.is-text-out {
    transform: translateX(calc(var(--text-out, 0) * -105%)); opacity: calc(1 - var(--text-out, 0));
  }
}
