/* ============================================================
   HERO — geometric mask transition, ported from labs-clone
   Changed vs. the original: the deck is driven by VERTICAL page
   scroll (sticky stage inside a tall track) instead of prev/next
   buttons, so a normal downward scroll walks the slides and then
   releases the page to the section below.
   ============================================================ */

.hero {
  /* ---------- how much scroll one slide costs ----------
     🔴 300svh, not 100. At one viewport per slide the deck was four
     viewports end to end, and a single trackpad flick on macOS carries
     1000–2000px of momentum — more than a whole slide, often two, and the
     tail runs straight out of the section. That is the "一滑就滑走了".

     Measured off zipline.com, which is the reference and does not have
     the problem: their pinned block is a 10800px pin-spacer at a 900px
     viewport — twelve viewports — across four slides. Three viewports per
     slide, exactly. The resistance is bought with SCROLL DISTANCE, not by
     hijacking the wheel: the same flick now covers less than half a slide
     and the snap pulls it back to the anchor.

     (They also run Lenis, which damps the momentum tail. That is a second,
     separate lever — this one is free.) */
  --slide-span: 300svh;
  height: calc(var(--hero-slides, 4) * var(--slide-span));
  position: relative;
}

.hero__stage {
  position: sticky;
  top: 0;
  height: 100svh;
  /* 🔴 min() — a bare 640px floor makes the stage TALLER THAN THE WINDOW on
     any screen shorter than that, and a sticky box with overhang has to
     give that overhang back at the end of its range. It gives it back on
     the last slide, so slide four alone rode 20px up into the clock (which
     is fixed to the viewport and does not move with it) while the other
     three sat correctly. Measured at 900×620: stage 640, window 620, slide
     four's content top 255 against 275 for the rest. The floor is only
     there to stop the stage collapsing, so capping it at the viewport
     keeps the intent and removes the slack. */
  min-height: min(640px, 100svh);
  /* the film's box, named once so the rail can hang off its right edge
     without either of them repeating the other's geometry */
  --film-x: max(3vw, 24px);
  /* 🔴 50vw, not 50%. Identical on screen (this box IS the viewport width,
     and macOS overlay scrollbars take nothing off it), but a percentage
     changes MEANING with the axis it is used on — width in `left`, height
     in `bottom` — and the rail needs this same number down both. As a vw
     length it is one value everywhere. */
  --film-w: min(50vw, 100svh);
  --rail-gap: 12px;
  overflow: hidden;
  isolation: isolate;
  background: var(--stage-bg, var(--page-bg));
  color: var(--ui-fg, #1a1a1a);
  transition: background-color .6s ease, color .6s ease;
}

.hero__stack { position: absolute; inset: 0; }

/* simple fade between slides — no geometric mask */
.hero__slide {
  position: absolute; inset: 0; z-index: 1;
  background: var(--slide-bg, var(--page-bg));
  color: var(--slide-fg, #1a1a1a);
  display: flex;
  flex-direction: row;
  align-items: center;
  opacity: 0;
  transition: opacity 0.7s ease;
}
.hero__slide.is-active {
  opacity: 1;
  z-index: 2;
}
.hero__slide.is-out {
  opacity: 0;
  z-index: 1;
}
/* ---------- the photographic ground ----------
   The picture rides .hero__inner, the layer that counter-rotates. So the
   mask turns and the photograph does NOT — the shape reads as a window
   opening onto something already there, rather than as a picture being
   spun. That is what this otherwise-empty layer has always been for.

   The scrim is on the same element, over the photo and under the copy: the
   type on this slide is white, and a kitchen shot is mostly white fridge
   and cream wall. Weighted to the foot, where the copy sits, so the top of
   the picture is left alone. */
.hero__inner {
  /* LEFT HALF — the rounded square the film plays in. (It was briefly
     full-bleed with the copy over it; back to the two-column composition.) */
  flex: none;
  width: var(--film-w);
  aspect-ratio: 1;
  position: relative;
  overflow: hidden;
  /* 🔴 cover + centre, and it is STABLE here in a way it is not on the flip
     card: this box is `aspect-ratio: 1` at every width, so the aspect never
     travels and cover cannot re-derive the crop underneath the picture. The
     card's box DOES travel — 1.60 to 1.00 across the turn — which is why its
     rule is width-bound instead; see .flip__back img in intro.css. For the
     portrait stills the deck carries, the two resolve to the same scale and
     the same centre on a square box, so the landing is one crop handed over
     rather than two that resemble each other.

     ⚠️ Both rules assume a PORTRAIT picture. A landscape one would still
     cover here and would letterbox on the card. Change the pair together. */
  background-image: var(--slide-img, none);
  background-size: cover;
  background-position: center;
  border-radius: 24px;
  margin-left: var(--film-x);
}
.hero__video {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  pointer-events: none;
}


/* RIGHT HALF — the text column. flex: 1 takes whatever the square film
   leaves, and the copy is ranged left inside it. */
.hero__content {
  flex: 1;
  min-width: 0;
  /* 🔴 stretch, or the padding below does nothing. The slide is a flex row
     with align-items:center, so this column is only as tall as its own
     text and is then centred as a block — `justify-content: flex-start`
     inside it has no free space to work with, and the padding just pushes
     the whole centred lump down. Stretching gives the column the stage's
     height, which is what the alignment needs to bite on. (The film keeps
     the row's centring: it sets its own height from its aspect-ratio.) */
  align-self: stretch;
  position: relative; z-index: 3;
  /* 🔴 Centred again — but the stretch above still matters. The clock used
     to be docked above the eyebrow, so moving the copy moved the time with
     it; it now reads off the film's top edge instead (see hero.js), which
     leaves this column free to put the eyebrow and the headline where they
     read best, in the middle of the frame. */
  /* 🔴 TOP of the frame, not the middle — and the top it lines up with is
     the FILM's, not an eyeballed inset. The column is stretched to the full
     stage height (above), so the film's top edge is (stage − film) / 2 down
     from it, and the same two names the film is built from give the copy its
     offset. One geometry: retune --film-w and the headline follows the
     picture instead of drifting off it.

     (100svh, not 100%: a percentage padding resolves against the containing
     block's WIDTH, which is the one dimension this has nothing to do with.
     The stage is 100svh tall — its min-height floor is itself capped at
     100svh, so the two can never disagree.) */
  /* the max() is a FLOOR, not a taste tweak: on a wide-and-short window
     (50vw > 100svh) the film is capped at the stage height, its top edge
     lands at 0, and aligning to it exactly would set the headline against
     the top of the screen.
     🔴 88, not 32. The floor's job is to keep the headline off the top of
     the screen, and 32 did not do it: the BAR is there. Measured at 1440
     wide, the header's bottom edge is 66px, so every window shorter than
     ~810 — where the film's own centring stops winning the max — put the
     first line of the deck straight under the pills.
     And 88 rather than a rounder 96 because the number has to stay UNDER
     the film's offset at the reference window: at 1440×900 that offset is
     (900 − 720) / 2 = 90, so 96 would quietly win the max there and break
     the one thing this padding exists for — headline top on film top.
     88 clears the bar by 22px and leaves every taller window untouched. */
  /* 🔴 CENTRED, which reverses what the notes above argue for. "Headline
     top on film top" is only the same thing as level-with-the-picture when
     the copy is as tall as the picture, and it is not: measured at 1440×900
     the headline and eyebrow come to ~178px of ink inside a 900px column,
     so top-aligning left 632px of nothing beneath them and the whole block
     read as having slipped up the frame. The film is genuinely centred —
     90px clear above and below — and this makes the words agree with it.

     The padding stays derived from the film rather than eyeballed; it is
     symmetric now, so it no longer decides where the copy STARTS, only how
     much of the column it may use. */
  padding: max(88px, calc((100svh - var(--film-w)) / 2)) max(5vw, 48px);
  display: flex; flex-direction: column;
  align-items: flex-start; justify-content: center;
  text-align: left;
  pointer-events: none;
}
.hero__content > * { pointer-events: auto; }

.hero__title {
  font-family: var(--font--primary);
  /* 🔴 The titles carry NO authored break any more — each is one sentence
     and the column decides where it turns. The size therefore no longer
     has a line fit to defend: the note that used to live here worked out
     that the longest authored line was 28 characters and warned that it
     needed ~448px against 366px of column at 430 wide, which is exactly
     the kind of arithmetic that stops being true the moment the copy is
     edited. A wrapped line just takes another row.
     Brought down from 5vw/72px — at the old size the two lines filled most
     of the frame's width and read as shouting rather than as a caption to
     the shape behind them, then down again to a 24px ceiling.

     🔴 The ceiling is a CEILING, not a fixed size: any desktop from ~1410px
     wide up renders exactly 38px, and below that the vw slope takes over so
     the line still shrinks with a phone rather than sitting at 38 on a
     390px screen. That keeps the "relative to the screen, not fixed" rule
     the buttons follow.

     🔴 The break is AUTHORED again, and that is what paid for the size. The
     note above is kept because its warning still stands — but the copy it
     was written against ran to 28 characters a line and let the column
     choose the turn, so the size had to be small enough that the column
     never chose a third one. The four titles are now written as two short
     lines each (longest: "someone to share it.", 20 characters) with the
     break in the markup, so the only thing the size has to clear is ONE
     20-character line, not a paragraph looking for a fit. That is 8 fewer
     characters to seat, and the type goes up by about a third for it.

     🔴 The slope is still the load-bearing half. Measured at 1512 / 1440 /
     1280 / 1100: usable column (stage minus film minus both gutters) runs
     ~560 / 533 / 474 / 397px, and the longest line at 3.6vw sets to ~490 /
     466 / 415 / 356. Every one clears with room. Re-check those four if a
     line ever goes past 20 characters. */
  /* 🔴 The slope is set by the COLUMN, not by taste, and the NARROW screen
     is the one that decides it. The copy column is whatever the square film
     leaves — 560px at 1512, 474px at 1280 — and the longest authored line
     has to fit inside it. Measured at 3.9vw:

         1512   59.0px   longest line 538 of 560   22px spare
         1280   49.9px   longest line 455 of 474   19px spare

     so 1280 is the binding case: past roughly 4.05vw that slide wraps to
     three lines while 1512 still looks fine. The 62px ceiling only bites
     above ~1590px.

     🔴 3.9 → 3.7vw, and the binding line changed with the copy. Slide two
     is "Understand the moments that matter." now, and it is the longest
     of the four by some way. Measured at 1440, where the text column is
     518px: at 3.9vw (56.2px) its natural wrap needs 530px for "moments
     that matter." — twelve short — so it fell to THREE lines while the
     other three slides stayed on two, which moves the eyebrow under it
     and shows up the moment the deck cross-fades. At 3.7vw (53.3px) the
     same line measures 503px: fifteen of headroom, ~3%, which is enough
     to survive a different platform's rasteriser. Nothing is authored —
     the column still decides where it turns; the size is just small
     enough that it turns in the right place.

     Re-measure if the copy gets longer again: the numbers that matter
     are the column's width and the longest UNBREAKABLE run in a title,
     not the character count. */
  font-size: clamp(26px, 3.7vw, 62px);
  line-height: 1.08;
  font-weight: 500;
  letter-spacing: -.03em;
  max-width: 100%;
  margin: 0;
}
/* 🔴 Not italic. The second half used to lean, on the idea that the two
   clauses read as a call and an answer — at this size it just looks like a
   different typeface has been dropped into the middle of a sentence. The
   line break is doing that job already. */
.hero__title em { font-style: normal; font-weight: inherit; }

/* the word the intro hands down. It is hidden for as long as the carrier
   is still flying it here (intro.js writes --carry-in on .hero), and there
   is no fade on either side: at the end of the flight the carrier is on
   exactly this word's box, so swapping one for the other is invisible,
   while any overlap would show the word twice. */
.hero__carry { font-style: inherit; opacity: var(--carry-in, 1); }
/* 🔴 The padding is the DESCENDER ALLOWANCE, and it is the only thing
   standing between "the growth you see." and a flat-bottomed g. A line box
   ends at the font's descent, but the ink of g/y/p/j does not — at 56px it
   was measured sitting 3px inside a 12px allowance, which is not a margin,
   it is luck. Raised to .3em, with the negative margin taking the same
   amount back so the block occupies exactly the space it did before: the
   allowance is invisible to the layout and only the clip sees it.
   (Measured with a canvas ink box, not by eye — see the note on the span
   below for the OTHER box that was cutting the same letters.) */
.hero__mask { display: block; overflow: hidden; padding-bottom: .3em; margin-bottom: -.22em; }
/* ---------- the copy waits for the turn to finish ----------
   🔴 NOT a taste decision — it is working around a real clipping failure.
   When a masked element is rotated AND its child is counter-rotated (which
   is exactly what this deck does: the slide spins, .hero__content spins
   back so the words stay upright), Chrome gives the counter-rotated child
   its own compositing layer and STOPS APPLYING THE ANCESTOR'S MASK TO IT.
   The background obeys the clover; the text does not, and renders in full
   across the ground outside it. That is the "rectangle showing through" —
   it was never the shape leaking, it was the type escaping.

   Reproduced down to a minimal case: same mask, same markup, one copy with
   no rotation and one with the slide at -130° and the content at +130°.
   The unrotated one clips to the silhouette. The rotated one does not.

   So nothing readable may be on screen while the spin is running. The spin
   is 1s (see --spin-dur), so the copy starts at 1.05s — by which point the
   counter-rotation has landed, the layer is square with the world again,
   and the mask is past full-bleed anyway so there is nothing left to clip.

   The alternative fix is to lift .hero__content out of the masked element
   entirely. That is the more correct structure and a bigger change; if this
   deck ever needs copy visible DURING the turn, that is the way to do it. */
/* 🔴 It comes in FROM THE LEFT, which is the side the film is on. The
   reveal used to be vertical — the line rose 105% out of its own mask —
   and that was a motion of its own, unrelated to anything else on screen.
   The card lands on the left and the copy sits to its right, so wiping the
   words out of that edge reads as the card putting them there rather than
   as a second thing arriving.

   A clip, not a slide: the words are already in position and the wipe
   uncovers them, so nothing travels the width of the column. The .35em of
   translate is only the push behind the wipe.

   🔴 No transition-delay any more. The .8s was there to keep type off the
   screen while the old spin ran; the cue now arrives with the card, and a
   delay on top of it is exactly the pause this was meant to remove. */
/* 🔴 The vertical insets are NEGATIVE, and that is the actual fix for the
   clipped descenders. `inset()` resolves against this span's own border
   box — which has no padding, so it ends exactly at the last line box, a
   good 3px above where the g's ink does. The mask above it carries a
   descender allowance in its padding; this clip did not, so the outer box
   was generous and the inner one sliced the letters off anyway. Two boxes,
   one of them silently tighter than the other.

   Pushing the top and bottom edges out past the box means the wipe can
   only ever cut HORIZONTALLY, which is all it was ever meant to do — the
   descender allowance is left to the mask's padding, where it belongs, and
   there is no second number to keep in step with the type. */
.hero__mask > span {
  display: block;
  clip-path: inset(-.35em 100% -.35em 0);
  translate: -.35em 0;
  transition:
    clip-path .9s var(--ease-out-expo),
    translate .9s var(--ease-out-expo);
}
/* 🔴 UNDER the headline now, not over it. As a standing head it was the
   first thing read on every slide, which made four slides open with four
   category names; read after the line it does what a caption does — you
   take the sentence, then you are told which part of the app it belongs
   to. The margin flips with it: it hangs off the bottom of the title. */
.hero__eyebrow {
  margin: 22px 0 0;
  font-size: clamp(12px, .95vw, 14px);
  font-weight: 500;
  letter-spacing: .12em;
  text-transform: uppercase;
  /* 🔴 The slide's own ink, held back — NOT a brand colour. This was tried
     in --nes-blue-2 and taken back out: at 13.7px this is small text, where
     the bar is 4.5:1, and on --nes-sand none of the three brand blues gets
     near it — #2497f0 is 2.77:1, #14a4fd 2.41:1, #1e88e5 3.28:1. The ink at
     the opacity below composites to #6d6c69 and measures 4.68:1, which is
     the only one of the four that passes. A label under a headline is
     supporting type; it wants to recede, and grey is what receding looks
     like. */
  color: currentColor;
  opacity: 0; transform: translateY(12px);
  /* same reason as the headline above — nothing legible during the spin */
  transition: opacity .7s ease, transform .7s var(--ease-out-expo);
}
/* 🔴 The cue is .is-copy-in, NOT .is-active. Slide one is active from page
   load — it has to be, it is the deck's resting state — so hanging the copy
   on that played its entrance while the reader was still three sections
   above it, and by the time the flip delivered them here the words had been
   sitting in place for a minute. The flip adds this class as it lands (see
   intro.js); every other slide gets it when it becomes the active one,
   which is the old behaviour. */
/* the same negative top and bottom as the closed state — only the RIGHT
   edge moves, so the clip is a horizontal wipe and nothing else */
.hero__slide.is-copy-in .hero__mask > span { clip-path: inset(-.35em 0 -.35em 0); translate: 0 0; }
/* the .62 is what makes it grey — see the note on .hero__eyebrow above */
.hero__slide.is-copy-in .hero__eyebrow { opacity: .62; transform: none; }
/* 🔴 Armed, not animated. While the flip's bridge is carrying these words on
   a fixed clone, the real line is hidden and set to its FINISHED state — no
   transition, because a timed one started here would still be running when
   the bridge lets go and the reader would watch the same line wipe in twice.
   intro.js drops this class at the pin, so every later slide change gets the
   normal entrance above. */
.hero__slide.is-copy-armed .hero__mask > span,
.hero__slide.is-copy-armed .hero__eyebrow { transition: none; }

/* ---------- vertical rail (was a horizontal prev/next row) ---------- */
/* Against the FILM's right edge, zipline-style: the rail is the film's
   own scrubber, so it belongs to the picture rather than to the page.
   (It used to sit out on the far right gutter, a whole text column away
   from the thing it measures.)

   🔴 The offset is composed from --film-x and --film-w, the same two
   values the film itself is built from, so there is one geometry and not
   two that have to be kept in step. Both are resolved against the same
   box — the film's 50% is of the slide's width, the rail's is of the
   stage's, and those are the same element's width — so the edges line up
   exactly at every viewport.

   Bottom edge to the film's bottom edge, as the reference has it. The
   film is square (aspect-ratio 1, so its height IS --film-w) and centred
   in a stage exactly 100svh tall, which puts its foot half the leftover
   height up from the floor — that is the whole of the calc below, and it
   holds at any window because both terms are real lengths. */
.hero__rail {
  position: absolute;
  /* the film's own scrubber, on its inboard edge — the side facing the
     copy */
  left: calc(var(--film-x) + var(--film-w) + var(--rail-gap));
  right: auto; top: auto;
  bottom: calc((100svh - var(--film-w)) / 2);
  transform: none;
  z-index: 5;
  display: flex; flex-direction: column; align-items: center; gap: 18px;
  /* 🔴 The rail FADES IN at the seam, it does not ride in. It lives in the
     stage, so through the intro's last viewport it was travelling up the
     screen with everything else — a scrubber sliding up from the floor
     before there is anything to scrub, while the flip's card is still in
     the air. intro.js holds it at 0 for as long as the bridge is up and
     lets go at the pin; this is the fade that then plays. */
  transition: opacity .5s ease;
}
/* 🔴 NO padding. The rule above puts the rail's BOX on the film's foot, and
   a 14px bottom padding then held the dots 14px clear of it — so the thing
   that was aligned was the box, and the thing you can see was not. The
   dots ARE the rail; its edges have to be theirs. (The top 14px was
   invisible either way — the rail is bottom-anchored and grows upward, so
   that padding only ever pushed empty space into thin air.) */
.hero__progress {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  padding: 0;
}
.hero__dot {
  position: relative;
  width: 8px; height: 8px;
  border: 0; padding: 0;
  border-radius: 1000px;
  /* tinted from the slide's ink, not a fixed white — but as a mix, so the
     fill inside stays at full strength */
  background: color-mix(in srgb, currentColor 28%, transparent);
  overflow: hidden; cursor: pointer;
  transition: height .3s var(--ease-out-quint), background .3s ease;
  flex: none;
}
.hero__dot.is-active { height: 76px; background: color-mix(in srgb, currentColor 22%, transparent); }
/* the fill stays with the bar to the end of the deck: nothing takes it
   away at the seam any more (see the note at the head of the hub's
   headline block in hub.css), so the rail simply goes up and out with
   the section it belongs to. */
.hero__dot i {
  /* 🔴 Top-anchored, so it fills DOWNWARD. It was `inset: auto 0 0 0` —
     pinned to the bottom — which grows the bar upward while the page is
     going down: the one thing on screen whose job is to say "you are
     moving down" was the only thing moving up. */
  position: absolute; inset: 0 0 auto 0;
  /* 🔴 The fraction arrives as --fill, not as a hard-coded property. hero.js
     paints one number per frame and the RAIL'S AXIS decides what it means:
     a height going down the desktop column, a width going across the phone
     row (see the media query at the foot of this file). Writing `height`
     from the script would have made the phone rail need a second painter. */
  height: var(--fill, 0); background: currentColor; border-radius: 1000px; display: block;
  /* 🔴 NO transition. This height is rewritten every animation frame from
     the film's own currentTime, so a .25s tween on top of it is a second
     animator lagging behind the first — the bar visibly stutters, and on a
     reset it sweeps backwards down the rail instead of simply going. The
     per-frame write IS the animation. */
}


/* ============================================================
   PHONE — the two columns become two ROWS: film over copy.

   🔴 The side-by-side composition does not survive a phone, and it fails
   on the COPY side rather than the film's. The row splits the width in
   half, so at 390 the film takes 195 and the text column is left with
   171 — out of which .hero__content's own max(5vw, 48px) gutters take 96,
   leaving 75px of measure. Measured: "Know your family better" set five
   lines, one word each, in a 75px ribbon beside a 195px picture. Every
   term in that chain is doing what it was written to do; there is just no
   width left to divide.

   Stacked, the film takes the column's full width and the copy gets the
   same 342 the picture has, so that headline comes back to ONE line at
   300px, at the size it was already asking for. Nothing here changes the
   desktop numbers — this block only untangles the ROW.
   ============================================================ */
@media (max-width: 767px) {
  /* one number for how far the rail sits off the floor — the slide reserves
     it, the rail spends it, so neither can drift from the other */
  .hero__stage { --rail-floor: max(20px, 4svh); }

  /* film first, copy under it, the pair centred as one block. Centred and
     not top-anchored because the titles are 1–3 lines: an anchor holds the
     film still and lets the bottom gap swing by ~60px from slide to slide,
     which reads as the picture jumping when the copy changes. */
  .hero__slide {
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: clamp(20px, 3.4svh, 34px);
    /* 🔴 The rail's strip, kept out of the centring. The rail is anchored
       to the stage floor and the block is centred in the stage, so on a
       short window the two solve for the same pixels and the eyebrow ends
       up sitting on the dots — measured on a 756×413 window: copy bottom
       391 against a rail top of 385. Reserving the strip as padding takes
       it out of the box the block is centred in, so they cannot meet.
       (--rail-floor + the dot + clearance.) */
    padding-bottom: calc(var(--rail-floor) + 8px + 20px);
  }

  /* 🔴 A percentage of the COLUMN, not a --film-w rewritten in vw: 100vw
     would also have to know about the scrollbar, which on a narrow desktop
     window is 15px the film would hang over the edge by. aspect-ratio
     still gives it its height.

     🔴 …and capped by the HEIGHT that is left over, because this query
     catches SHORT windows too, not only phones. A 767-wide window 600 tall
     would take the full 719 of column width, and a square film 719 tall
     does not fit a 600px stage — .hero__stage clips its overflow, so the
     picture would lose its head and its feet with no way to tell from the
     markup. The 270 is everything else the stage owes: the copy's worst
     case (a three-line title plus the eyebrow, ~176), the gap above it
     (34) and the rail's strip along the floor (~60, reserved as this
     slide's padding-bottom below).

     🔴 A SUBTRACTION, not a fraction of the height. Any fixed NNsvh is
     wrong at one end or the other — 64svh clears a 413px-tall window by
     264 when the leftovers need 262, i.e. it fits by 2px, and at 844 it
     would be capping a film the width had already settled. Taking the
     leftovers off the top is the same statement at every height.

     On every real phone the column is the smaller of the two and the
     height term never bites: at 390×844 it is 342 against 574. The 140px
     floor is only there so a window short enough to drive this negative
     yields a small film rather than an invalid width.

     Ranged left rather than centred, so the film's left edge is the copy's
     left edge in the case where the cap does bite and the two are not the
     same width. */
  .hero__inner {
    width: max(140px, min(calc(100% - 2 * var(--film-x)), calc(100svh - 270px)));
    align-self: flex-start;
    margin: 0 var(--film-x);
  }

  /* the copy's left edge is the FILM's left edge — same gutter, one column.
     The desktop rule's vertical padding is derived from the film's centring
     in the row and means nothing once they are stacked, so it goes; the gap
     above owns the space between them now. */
  .hero__content {
    flex: 0 0 auto;
    padding: 0 var(--film-x);
    justify-content: flex-start;
  }

  .hero__title { font-size: clamp(26px, 7.4vw, 40px); }

  /* ---------- the rail lies down ----------
     🔴 It goes to the FLOOR of the stage, not under the film. On desktop it
     hangs off the film's inboard edge, which it can do because that edge is
     at a fixed x — `--film-x + --film-w`. Stacked, the film's inboard edge
     is its BOTTOM, and that y is (stage − block) / 2 + film, where the
     block's height includes the copy — i.e. it moves with the title's line
     count, and the rail lives outside the slides (see hero.js: it is a
     sibling of .hero__stack) so it cannot read it from the flow. Anchored
     to the stage floor it is deterministic at every slide, and a centred
     row of dots under the content is what a phone reads as the pager
     anyway. */
  .hero__rail {
    --rail-gap: 8px;
    left: 0; right: 0;
    top: auto; bottom: var(--rail-floor);
    flex-direction: row;
    justify-content: center;
    gap: 12px;
  }
  .hero__progress { flex-direction: row; gap: 10px; }
  /* the active dot grows along the axis it is now laid out on */
  .hero__dot.is-active { width: 76px; height: 8px; }
  /* and its fill runs left-to-right, the direction the deck advances */
  .hero__dot i { inset: 0 auto 0 0; width: var(--fill, 0); height: auto; }
}

@media (prefers-reduced-motion: reduce) {
  .hero__slide.is-anim,
  .hero__slide.is-anim .hero__inner,
  .hero__slide.is-anim .hero__content { animation: none; }
}
