/* WYW — landing page styles: loader, hero scroll story, how-it-works,
   queue banners + "no more" story. Loads after base.css. */

:root {
  --phone-h: min(90svh, 182vw);
  --phone-ratio: 0.472; /* width / height, iPhone-ish */
  --screen-w: calc(var(--phone-h) * var(--phone-ratio));
  --phone-radius: clamp(28px, 6.2svh, 54px);
}

body {
  background: var(--green);
  color: var(--cream);
}

/* The page ends on the green band, so the CANVAS has to be green too. If the
   viewport is ever taller than the document — iOS retracting its chrome at
   max scroll, or a rubber-band overscroll at the bottom — the strip that
   opens below the footer is painted by the canvas, and the footer's own
   sheet is PAPER. Setting it on <html> stops <body>'s background being the
   thing that propagates and pins the answer to green either way. Reported
   2026-08-09 as a band of page texture below the footer on iPhone. */
html { background: var(--green); }

/* ---------- Loader ---------- */

/* HIDDEN BY DEFAULT (2026-08-10). The loader used to be visible from the
   first byte and removed only by main.js — so if GSAP, ScrollTrigger or
   main.js failed to load or parse, JavaScript was still "on" (so <noscript>
   did not apply) and the fixed sheet covered the homepage forever. One
   blocked CDN request or a corrupt file turned the site into a permanent
   loading screen.

   The default is now the readable page, and the loader is OPT-IN: the tiny
   inline boot script in index.html's <head> adds `is-enhanced` to <html>
   before the first paint, so a working browser sees the identical intro with
   identical timing, and a broken one simply never raises the curtain it
   cannot lift. See that script for the failsafes that also drop the loader
   if the boot succeeds but the animation code dies afterwards. */
.loader {
  position: fixed;
  inset: 0;
  z-index: 100;
  /* same single-sheet paper as the other cream surfaces (BG 2 export) */
  background: var(--paper) url('../assets/bg-paper-3200.jpg') center / cover no-repeat;
  display: none;
  place-items: center;
}

.is-enhanced .loader { display: grid; }

/* The loader IS the hero finale's box (Tom, launch day: same size, same
   aspect): the square ink squircle with the cream monogram inside, so the page
   opens and closes on the identical object. Same numbers as .app-icon's box —
   mark ink 62% of the tile, centred at 50%, radius 26% of the side. The tile
   side comes from the MARK, which keeps rendering at its historic clamp.
   2026-08-10: the tagline is gone from here entirely (Tom), so this is the
   monogram box and nothing else — the finale keeps its "while you wait", but
   OUTSIDE the box; the loader does not get one at all. */
.loader__box {
  /* the historic mark clamp at 70% — Tom sized the tile down 30% on launch
     day; every box number derives from this, so it scales as one object */
  --mark: clamp(77px, 9.8vw, 133px);
  position: relative;
  width: calc(var(--mark) / 0.62);
  aspect-ratio: 1;
  background: var(--ink);
  border-radius: calc(var(--mark) * 0.4194);
  /* hidden until main.js POPS it in whole (no wipe — Tom, pre-launch);
     autoAlpha animates both halves of this */
  opacity: 0;
  visibility: hidden;
}

.loader__mark {
  position: absolute;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* The monogram is ALONE in the tile now (Tom, 2026-08-10), so it sits dead
   centre instead of riding 11.84% high to make room for a tagline. 62% is the
   mark's historic ink ratio and the one the nav tile mirrors - don't move it.
   The old .loader__word rule went with the element; the curtain's shed step
   went with it too (see liftCurtain in main.js). */
.loader__mark { width: 62%; top: 50%; }

.loader--done { display: none; }

/* ---------- Hero ---------- */

.hero {
  /* the same iOS chrome-gap guard as .s3: the strip behind the 100svh stage
     shows this, so it must be the stage's own green, not body paper */
  background: var(--green);
  /* Scroll distance for the whole story (timeline ends at 500vh — see the
     matching 5.0 multiplier in main.js); then 50vh of paper blow-up
     (500-550, see renderBleed) and 100vh of the s2 curtain sliding over
     the full-bleed paper (550-650). Tightened per design feedback — the
     icon-to-s2 run-out had ~100vh too much dead scroll.

     PRE-JS / NO-JS VALUE ONLY. main.js writes this box as
     6.5 x window.innerHeight px — the same number every hero trigger is
     measured in — so the two can never disagree (on iOS `vh` is the LARGE
     viewport while innerHeight at load is the SMALL one, and the surplus
     was 600-950px of dead paper before s2). See sizeHeroBox() in main.js;
     the inline px beats this rule. The noscript block overrides it to
     100svh anyway. */
  height: 650vh;
  position: relative;
}

.hero__stage {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
  background: var(--green) url('../assets/bg-green-halftone.jpg') center / cover no-repeat;
}

/* Full-bleed scene videos behind the phone (one per beat, crossfaded).
   Masked to a soft column around the phone (design feedback): opaque across
   the phone's width at its largest scale (1.45, so the in-screen film is
   never clipped), fading to the identical stage halftone toward the edges.
   Sized off --screen-w so any aspect ratio works — on narrow viewports the
   band + fade exceed the width and the mask is effectively full-bleed. */
.stage-films {
  position: absolute;
  inset: 0;
  --film-band: calc(var(--screen-w) * 0.725); /* half phone width @ scale 1.45 */
  -webkit-mask-image: linear-gradient(90deg,
    transparent calc(50% - var(--film-band) - 14vw),
    #000        calc(50% - var(--film-band)),
    #000        calc(50% + var(--film-band)),
    transparent calc(50% + var(--film-band) + 14vw));
  mask-image: linear-gradient(90deg,
    transparent calc(50% - var(--film-band) - 14vw),
    #000        calc(50% - var(--film-band)),
    #000        calc(50% + var(--film-band)),
    transparent calc(50% + var(--film-band) + 14vw));
}

.film {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  will-change: opacity;
}

/* ---------- No native player chrome on any of our inline clips ----------
   iOS refuses even MUTED autoplay in Low Power Mode and in most in-app
   webviews: the element stays paused on its first frame and WebKit paints
   its own large centred start-playback glyph on top. Every video on this
   page is decoration played through scroll — the films behind the phone,
   the collage inside the screen, the how-it-works clips — and the films are
   full-bleed, shown through the wash mask's hole, so that glyph lands dead
   centre of the phone screen and sits there for the whole story (reported
   from an iPhone 17 Pro in Low Power Mode, 2026-09-12).

   `:not([controls])` is the line between the two kinds of video we have:
   these, which must never show chrome, and a real player like the
   for-venues film frame, which is given `controls` on click. Chromium
   ignores both pseudo-elements; it honours the autoplay in the first place,
   so there is nothing there to hide. The affordance is not lost — main.js
   un-hides `.screen-play` inside the phone when play() is refused. */
video:not([controls])::-webkit-media-controls-start-playback-button {
  display: none !important;
  -webkit-appearance: none;
}

video:not([controls])::-webkit-media-controls {
  display: none !important;
  -webkit-appearance: none;
}

/* The wash: green halftone at ~0.7 over the films, with a phone-screen-shaped
   hole (two mask layers, full sheet minus rounded rect). main.js generates the
   rect SVG per resize and tracks the screen's rect per frame via
   mask-position/mask-size, so the clip is sharp in the phone and washed
   outside - one video, zero alignment drift. Where no film is playing, the
   wash sits over the identical stage halftone, so it reads as plain green. */
.stage-wash {
  position: absolute;
  inset: 0;
  background: var(--green) url('../assets/bg-green-halftone.jpg') center / cover no-repeat;
  opacity: 0.72;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* Ghost echo text on the background */

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

.ghost {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 0 2vw;
  font-size: clamp(70px, 11.5vw, 210px);
  font-weight: 700;
  line-height: 0.9;
  letter-spacing: -0.05em;
  text-align: center;
  color: var(--cream);
  opacity: 0;
  will-change: opacity, transform;
}

/* ---------- Phone ---------- */

.phone-wrap {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}

.phone {
  height: var(--phone-h);
  aspect-ratio: var(--phone-ratio);
  position: relative;
  border: 2.5px solid var(--cream);
  border-radius: var(--phone-radius);
  padding: clamp(4px, 0.9svh, 8px);
  will-change: transform;
  transform-origin: center center;
}

/* side buttons */
.phone::before,
.phone::after {
  content: '';
  position: absolute;
  width: 2.5px;
  background: var(--cream);
  border-radius: 2px;
}

.phone::before {
  /* volume keys, left side */
  left: -7px;
  top: 22%;
  height: 7%;
  box-shadow: 0 calc(0.09 * var(--phone-h)) 0 var(--cream);
}

.phone::after {
  /* power, right side */
  right: -7px;
  top: 26%;
  height: 9%;
}

.phone__screen {
  position: absolute;
  inset: clamp(4px, 0.9svh, 8px);
  border-radius: calc(var(--phone-radius) - clamp(4px, 0.9svh, 8px));
  overflow: hidden;
  /* transparent: the screen is a window onto the full-bleed .film behind
     (the wash mask's hole aligns with this rect) */
  background: transparent;
  transform: translateZ(0);
}

/* ---------- Screen layers ---------- */

.layer {
  position: absolute;
  inset: 0;
  opacity: 0;
  will-change: opacity;
}

.layer__photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.layer__shade {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(180deg, rgba(38,37,31,0.42) 0%, rgba(38,37,31,0.12) 35%, rgba(38,37,31,0.36) 100%),
    radial-gradient(120% 90% at 50% 45%, rgba(127,164,123,0) 55%, rgba(127,164,123,0.38) 100%);
}

.layer__shade--light {
  background:
    linear-gradient(180deg, rgba(38,37,31,0.28) 0%, rgba(38,37,31,0.06) 40%, rgba(38,37,31,0.30) 100%),
    radial-gradient(120% 90% at 50% 45%, rgba(127,164,123,0) 60%, rgba(127,164,123,0.30) 100%);
}

.layer__texture {
  position: absolute;
  inset: 0;
  background: var(--paper) url('../assets/bg-paper-3200.jpg') center / cover no-repeat;
}

/* persistent phone chrome: top gradient, logo (above the photo layers) */

.screen-top-fade {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 24%;
  z-index: 4;
  background: linear-gradient(180deg, rgba(38, 37, 31, 0.55) 0%, rgba(38, 37, 31, 0) 100%);
  pointer-events: none;
}

.screen-logo {
  position: absolute;
  z-index: 4;
  top: 7.5%;
  left: 50%;
  /* laid out at 2x and scaled to half: the SVG rasterises at double size, so
     it stays crisp while the phone rides at scale 1.45 (composited transforms
     reuse the layout-size raster and were blurring the logo) */
  width: 34%;
  transform: translateX(-50%) scale(0.5);
  transform-origin: 50% 0;
  /* the img height attribute must not win over the CSS width (it stretched
     the box and let the logo drift down the screen) */
  height: auto;
}

/* Foreground captions — inside the phone screen, sized to the screen.
   Spec: Alte Haas Grotesk Bold, 90% line-height, -5% tracking, centered */

.screen-captions {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}

.caption {
  position: absolute;
  left: 7%;
  right: 7%;
  top: 50%;
  /* vertical centering via GSAP yPercent so scroll-driven y stays composable */
  text-align: center;
  color: #fff;
  font-weight: 700;
  line-height: 0.9;
  letter-spacing: -0.05em;
  opacity: 0;
  text-shadow: 0 2px 18px rgba(38, 37, 31, 0.4);
  will-change: opacity, transform;
}

.caption--stat {
  font-size: calc(var(--screen-w) * 0.082);
  /* sits in the upper-middle of the screen (design), not dead centre — the
     oversized rest-state phone extends below the fold, so 50% reads too low */
  top: 27%;
  line-height: 1.15;
}
.caption--fiveyears { font-size: calc(var(--screen-w) * 0.19); white-space: nowrap; }
.caption--silly,
.caption--built     { font-size: calc(var(--screen-w) * 0.105); white-space: nowrap; }

/* ---------- Autoplay fallback control ----------
   The stand-in for the native glyph killed up by .film: main.js un-hides it
   only when play() is refused (Low Power Mode, in-app webviews), and one tap
   on it is the gesture that unlocks playback for the rest of the page.
   Deliberately quiet — it unblocks a decorative clip, so it is the house pill
   at roughly half the nav's size, not a call to action.

   WHY THE INSET IS 40% AND NOT FLUSH-BOTTOM. At rest the phone is scale 1.45
   AND translated down vh(32) (the `.phone` fromTo in main.js), so the bottom
   34.9% of the screen box is below the fold at 402x874 and 35.9% at 1280 —
   measured, and near-identical at both because --phone-h tracks the viewport
   either way (182vw on the phone, 90svh on the desktop). A control on a small
   inset would therefore be UNREACHABLE in the resting first view, which is
   exactly where a blocked visitor lands and exactly where the native glyph
   used to sit. The inset is measured against the scroll cue, which is a
   stage element and does not move with the screen: at 402x874 the cue's
   arrow ends 122px off the fold, so 40% (the phone block) puts the pill
   BELOW it with 13px between and 54px of clearance; at 1280x800 the arrow
   ends only 32px off the fold and 40% lands the pill on the arrow, so here
   it takes 48% and sits 19px ABOVE the cue line. Either way it rides down
   with the screen as the phone settles. Same reasoning as
   .caption--stat sitting at 27% rather than 50%. A percentage, not pixels, so
   it holds its place as the phone scales. The pill is far narrower than the
   screen, so the corner radius is never near it. */
.screen-play {
  position: absolute;
  left: 50%;
  bottom: 48%;
  z-index: 6; /* over .screen-captions (5) and every story layer */
  transform: translateX(-50%);
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 38px;
  padding: 0 17px 0 15px;
  border: 0;
  border-radius: 19px; /* pill */
  background: var(--cream);
  color: var(--green);
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.01em;
  cursor: pointer;
}

/* the author `display` above outranks the UA sheet's [hidden] rule, so the
   attribute has to be honoured explicitly or the control is never hidden */
.screen-play[hidden] { display: none; }

.screen-play__icon {
  width: 15px;
  height: 15px;
  flex: none;
}

@media (prefers-reduced-motion: no-preference) {
  /* it appears mid-page, after the story has already settled — a fade stops
     it snapping in. Reduced motion gets the same control, already opaque. */
  .screen-play { animation: screen-play-in 0.35s ease-out both; }
}

@keyframes screen-play-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* collage */
.layer--collage { background: var(--paper) url('../assets/bg-paper-3200.jpg') center / cover no-repeat; }

/* reveal: dot -> app icon */

.reveal-dot {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 8.5%;
  aspect-ratio: 1;
  margin: -4.25% 0 0 -4.25%;
  border-radius: 50%;
  background: var(--green);
  transform: scale(0);
  will-change: transform;
}

/* The box holds the whole lockup — both layers are placed against it
   absolutely, so nothing here lays anything out */
.app-icon {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 46%;
  aspect-ratio: 1;
  transform: translate(-50%, -50%) scale(0);
  border-radius: 26%;
  background: var(--ink);
  box-shadow: 0 18px 40px rgba(38,37,31,0.28);
  will-change: transform;
}

/* THE LOCKUP, RESHAPED 2026-08-10 (Tom, off bug #01): the monogram sits ALONE
   inside the ink box and "while you wait" sits OUTSIDE it, directly under, in
   ink on the paper reveal. Only this beat and the loader change — the nav tile,
   the footer and the subpages are untouched.

   The MARK KEEPS THE SIZE IT HAS ALWAYS HAD — 124% laid out, scaled 0.5, so
   its ink is 62% of the box. That is the ratio the nav tile mirrors and the
   whole dock rests on; changing it would move the landing. What changed is
   only where it sits: alone in the box it goes to 50% instead of riding
   11.84% high to make room for a tagline that is no longer in there. Because
   the mark is now centred at 50% at REST, it is already where the dock wants
   it and main.js no longer has to walk it there (see shedLockupText).

   The text keeps the delivered balance (2026-08-07 round 2, Bold text): its
   ink is 62% x 1.3238 = 82.08% of the box wide — the art carries a uniform
   3/613.31 bleed, so its BOX is 82.89%, laid out at 2x = 165.79% — and it is
   set below the box by the delivery's own gap, 0.2893x the mark's HEIGHT.
   Mark ink height = 62% x (396/610) = 40.249% of the box, so:
       gap        = 0.2893 x 40.249% = 11.644% of the box
       text ink h = 0.2993 x 40.249% = 12.046% of the box
       text top   = 100% + 11.644%   = 111.644%
       text CENTRE= 111.644 + 6.023  = 117.667%   <- the number below
   The bleed is uniform, so the art box's centre and the ink's centre are the
   same point and positioning the <img> centre positions the ink. The box is
   square, so a percentage means the same number on either axis. Both layers
   get the 2x-layout + half-scale treatment so the raster stays crisp under
   the icon's spring, and the text stays a CHILD of .app-icon so the pair pops
   in as one object. */
.app-icon__mark,
.app-icon__text {
  position: absolute;
  left: 50%;
  height: auto;
  transform: translate(-50%, -50%) scale(0.5);
}

.app-icon__mark { top: 50%; width: 124%; }
.app-icon__text { top: 117.667%; width: 165.79%; }

/* ---------- Paper blow-up (screen paper -> full bleed before s2) ----------
   Sits above the phone; starts clipped to the exact screen rect (identical
   paper, so the takeover is invisible) and expands to the viewport on the
   550-650vh dead zone. Clip + icon rect are driven from main.js. */

.paper-bleed {
  position: absolute;
  inset: 0;
  background: var(--paper) url('../assets/bg-paper-3200.jpg') center / cover no-repeat;
  visibility: hidden;
  will-change: clip-path, opacity;
}

/* app-icon handoff clone: tracks the real icon during the blow-up, then
   shrinks onto the nav logo's rect — same object at both ends since the tile
   went ink+cream (rect + fades driven per frame from main.js) */
.icon-handoff {
  position: fixed;
  z-index: 40; /* above the s2 curtain (z2), below the nav pills (z50) */
  visibility: hidden;
  border-radius: 26%;
  background: var(--ink);
  box-shadow: 0 18px 40px rgba(38, 37, 31, 0.28);
  pointer-events: none;
}

/* the clone carries the same lockup as .app-icon, at the same percentages of
   its own (square, while it shadows) box — it has to read identical: monogram
   centred inside, "while you wait" in ink under the box. main.js fades that
   text out over the start of the flight, so from there on this is the bare
   mark it has always flown, landing on the nav tile's centred glyph. The mark
   no longer needs walking to 50% — it is already there (2026-08-10). */
.icon-handoff img {
  position: absolute;
  left: 50%;
  height: auto;
  transform: translate(-50%, -50%) scale(0.5);
}

.icon-handoff__cream,
.icon-handoff__ink { top: 50%; width: 124%; }
.icon-handoff__text { top: 117.667%; width: 165.79%; }

.icon-handoff__ink { opacity: 0; }

/* the real header logo stays hidden while the handoff icon is en route */
body.logo-handoff .nav__logo { visibility: hidden; }

/* ---------- Hero scroll cue ----------
   A whisper on the resting first view: one line under the hand-drawn arrow,
   in the gutter to the RIGHT of the phone. (Replaces an unused uppercase
   .scroll-cue placeholder that had been sitting here since the elvalabs
   round, and its shadowed copy of the cue-bob keyframes.)

   Placement is forced by the rest-state geometry. The phone rests at scale
   1.45, so it is min(90svh, 182vw) x 0.472 x 1.45 wide and the clear gutter
   each side is (100vw - that) / 2 — measured 443px at 1440x900 and 311px at
   1024x768, but only 69px at 768x1024 and NEGATIVE at 390x844 (the phone is
   wider than the viewport there). The consent banner owns the bottom LEFT on
   first visit — and goes full-bleed below 640px — so the right gutter is the
   only corner clear of both. Hence the query below: inside it the gutter is
   at worst 173px (at 901x901), against a 132px cue on a 2.2vw inset = 152px.
   Section 2's .how__cue is the mobile counterpart, so the hand-drawn arrow
   still cues the scroll on phones — the two are never both on screen. */

.hero__cue {
  display: none;
  position: absolute;
  right: 2.2vw;
  bottom: 4.5svh;
  max-width: 150px;
  flex-direction: column;
  align-items: center;
  gap: 7px;
  text-align: center;
  color: var(--cream);
  /* main.js owns opacity + visibility (renderCue) — hidden from the first
     frame so nothing flashes under the loader or during the intro */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

/* Desktop: CENTRED at the bottom, the same as the portrait variant below.

   It used to sit in the right-hand gutter (`right: 2.2vw`), placed there to
   stay off the oversized phone. Read on the page it just looked lost — a
   line of cream type tucked against the right edge over the green, level
   with nothing, which is how all three reviewers reported it on 2026-08-09.
   A scroll cue belongs under the thing it is telling you to scroll. The
   phone's lower half is full-bleed film at this point, so cream-on-dark
   reads here exactly as it already does on phones, where this cue has always
   been centred over the same footage. */
@media (min-width: 901px) and (min-aspect-ratio: 1/1) {
  .hero__cue {
    display: flex;
    left: 0;
    right: 0;
    max-width: none;
  }
}

.hero__cue-line {
  font-size: 13px;
  line-height: 1.3;
  letter-spacing: -0.01em;
  opacity: 0.85;
}

/* the asset is INK on transparent, so it is a MASK over a cream fill rather
   than a tinted image — one mask layer on a childless span, not the
   two-layer mask-composite trick that breaks on composited subtrees (see
   Gotchas). The bob lives here, on the arrow alone: main.js never writes
   transform on this element, so GSAP and CSS never share a property. */
.hero__cue-arrow {
  width: 17px;
  height: 46px; /* the 44x120 asset, a shade up on .how__cue's mobile 15px */
  background: var(--cream);
  -webkit-mask: url('../assets/scroll-arrow.png') center / contain no-repeat;
  mask: url('../assets/scroll-arrow.png') center / contain no-repeat;
}

@media (prefers-reduced-motion: no-preference) {
  .hero__cue-arrow { animation: hero-cue-bob 2.2s ease-in-out infinite; }
}

@keyframes hero-cue-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(6px); }
}

/* ---------- Hero scroll cue · PORTRAIT (phones, portrait tablets) ----------
   The gutter the desktop cue lives in doesn't exist below 901 (at 390 the
   resting phone is WIDER than the viewport), but a phone is exactly where the
   scrollability signal is needed. So the cue moves inboard: centred over the
   phone's lower screen, at caption scale, same line + arrow.

   PORTRAIT ONLY — `not (aspect >= 1)`. It can never collide with the desktop
   query above, which requires aspect >= 1, so exactly one cue is ever on.
   Portrait tablets land here on purpose (their gutter is dead too). Landscape
   phones deliberately get NO cue, as they do today: at 844x390 the hero is
   squeezed to the point that any bottom-anchored offset clearing the consent
   card lands the cue straight through the in-phone caption (measured) — and
   section 2's .how__cue still carries the affordance down there.

   Bottom offset is set by the consent banner, which goes FULL-BLEED below
   640: measured 145.5px tall + its 12px inset = the bottom 157.5px at
   390x844 (and at 320 — it doesn't narrow). 24svh floats the cue 202px up,
   45px of daylight above the banner, and still reads as placed once the
   banner is dismissed. The px floor holds that clearance on short phones,
   where the banner is content-sized and does NOT shrink with the viewport.

   Everything else is shared with the desktop cue: same renderCue() opacity
   (main.js), same bob on the arrow, same pointer-events/aria. */
@media not all and (min-aspect-ratio: 1/1) {
  /* Nested rather than appended: `not all and (A) and (B)` would negate the
     PAIR. Below 641 tall the caption above and the banner below leave a 45px
     band for a 62px cue (measured at 320x568) — no offset fits, so those
     phones keep today's no-cue hero and .how__cue further down. 640 is the
     same short-phone line base.css already draws for the menu links. */
  @media (min-height: 641px) {
    .hero__cue {
      display: flex;
      /* full-width box + centred children rather than a translate: main.js
         leaves transform alone here, and the bob stays the only transform in
         this subtree (see Gotchas) */
      left: 0;
      right: 0;
      max-width: none;
      /* The caption is placed from the TOP (27% of the screen) and the cue
         from the BOTTOM, so the space between them is whatever the viewport
         leaves — and `max(24svh, 184px)` made that space collapse as screens
         got shorter. Measured caption-to-cue gap before: 36px at 428x926,
         27px at 393x852, 18px at 393x740 (an iPhone 15 Pro in Chrome, whose
         chrome is deeper than Safari's) and MINUS 10px at 390x664, i.e.
         touching. That is the "cue crowds directly under 'in queues', arrow
         over the photo" report of 2026-08-09.

         Clamping instead of flooring keeps the cue low on every screen: the
         gap now measures 128 / 112 / 98 / 81px at those same four sizes. The
         88px floor still clears the consent banner (40px at 390x844). */
      bottom: clamp(88px, 14svh, 150px);
      gap: 6px;
    }

    .hero__cue-line { font-size: 12px; }

    /* matches .how__cue's mobile arrow — the same hand-drawn mark at the same
       size, since on phones these two cue the same gesture */
    .hero__cue-arrow { width: 15px; height: 40px; }
  }
}

/* ================= Section 2 · how it works (paper theme) ================= */

.s2 {
  position: relative;
  z-index: 2; /* slides up over the pinned hero stage */
  /* no overlap with the hero any more: the paper blow-up (550-650vh) owns
     the transition, then this curtain rides over the full-bleed paper
     (650-750vh). Was -100svh when the curtain did the transition itself. */
  margin-top: 0;
  background: var(--paper);
  color: var(--ink);
  /* clips the fixed backdrop to the section box (keeps the curtain effect) */
  clip-path: inset(0);
}

.s2__bg {
  position: fixed;
  inset: 0;
  z-index: -1;
  /* single non-tiled sheet (BG 2 export) — the old 1300px tile showed seams */
  background: var(--paper) url('../assets/bg-paper-3200.jpg') center / cover no-repeat;
}

/* first screen of the curtain — breathing room before the scan/browse words
   (the fixed backdrop guarantees full coverage) */
.statement--first { min-height: 64svh; padding-bottom: 6svh; }

/* ---- how it works: one pinned view; scroll moves only the highlight + media ---- */

.how {
  /* 100svh view + ~220svh of scroll that drives the four steps */
  height: 320svh;
  position: relative;
}

.how__pin {
  position: sticky;
  top: 0;
  height: 100svh;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ---- the ticket rail (the component picked from the 2026-07-28 mockups):
   four stubs on one perforated strip; the open ticket springs wide via
   flex-basis, the stubs compress. Scroll decides which is open. ---- */

.rail {
  /* taller + narrower than v1 so the open media box sits near the clips'
     4:3 — the old 62svh × 88vw box cover-cropped the tops and bottoms off
     (content team feedback) */
  display: flex;
  width: min(1120px, 86vw);
  height: min(66svh, 600px);
  transform: rotate(-1.2deg);
}

.ticket {
  position: relative;
  flex: 0 0 88px; /* the stub width; flex-grow is written per frame by
                     railRender in main.js — the tear is scroll-scrubbed,
                     so there is deliberately NO transition here */
  min-width: 0;
  background: var(--cream);
  border: 2px solid var(--ink);
  overflow: hidden;
  cursor: pointer;
}
.ticket:first-child { border-radius: 16px 0 0 16px; }
.ticket:last-child { border-radius: 0 16px 16px 0; }
.ticket + .ticket { border-left: none; }
.ticket.is-open { cursor: default; }

/* perforation between tickets */
.ticket + .ticket::before {
  content: '';
  position: absolute;
  top: 10px;
  bottom: 10px;
  left: 0;
  width: 2px;
  background: repeating-linear-gradient(to bottom, rgba(38, 37, 31, 0.55) 0 7px, transparent 7px 15px);
}

/* punched hole — flat paper fill + ink ring. Deliberately NOT the fixed-
   attachment texture trick: background-attachment: fixed misaligns inside
   the sticky (composited) pin, and at 22px flat paper reads identically. */
.ticket__hole {
  position: absolute;
  top: 18px;
  left: 50%;
  width: 22px;
  height: 22px;
  transform: translateX(-50%);
  border: 2px solid var(--ink);
  border-radius: 50%;
  background: var(--paper);
  box-shadow: inset 0 1px 3px rgba(38, 37, 31, 0.25);
  transition: opacity 0.25s ease;
}
.ticket.is-open .ticket__hole { opacity: 0; }

.ticket__stubword {
  position: absolute;
  left: 50%;
  bottom: 26px;
  transform: translateX(-50%) rotate(180deg);
  writing-mode: vertical-rl;
  font-size: 28px;
  font-weight: 700;
  letter-spacing: -0.02em;
  white-space: nowrap;
  transition: opacity 0.2s ease;
}
.ticket.is-open .ticket__stubword { opacity: 0; }

/* open face — paints the moment the ticket goes active. It used to wait
   0.2s and then fade over 0.3s ("once the tear is mostly sprung"), which
   read fine at a stroll and never painted at a flick: in a fast tear
   "browse" and "queue" were active for 40-120ms each and their copy
   measured a maximum opacity of exactly 0 (scroll audit, 2026-09-11).
   The face now starts rising immediately and lands in 0.12s; the tear
   itself is still scroll-scrubbed (railRender), so nothing else moved. */
.ticket__open {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: clamp(16px, 2.2vw, 26px);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.12s ease, visibility 0s linear 0.12s;
}
.ticket.is-open .ticket__open {
  opacity: 1;
  visibility: visible;
  transition-delay: 0s, 0s;
}

.ticket__head {
  display: flex;
  align-items: baseline;
  gap: 22px;
  margin-bottom: clamp(18px, 2.8svh, 34px); /* air before the clip (feedback) */
  white-space: nowrap;
}

.ticket__word {
  font-size: clamp(40px, 4.2vw, 62px);
  font-weight: 700;
  line-height: 0.9;
  letter-spacing: -0.05em;
}

.ticket__inner {
  position: relative;
  z-index: 0; /* own stacking context so the marker ::before stays behind the text only */
  padding: 0.02em 0.1em;
  margin: -0.02em -0.1em;
}

/* the green marker is the step's PROGRESS BAR: it colours in behind the
   word as you scroll through the step (railRender writes --fill per frame;
   the tear to the next ticket only starts once it's full). No transition
   here on purpose — JS owns this transform every frame. */
.ticket__inner::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--highlight);
  transform: scaleX(var(--fill, 0));
  transform-origin: left center;
  z-index: -1;
}

/* same voice as the rest of the site's captions (the old step captions):
   full-ink regular at the standard caption scale — the first cut was small
   and greyed, which read as a different face (feedback) */
.ticket__cap {
  font-size: clamp(16px, 1.5vw, 24px);
  letter-spacing: -0.02em;
  line-height: 1.25;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ticket__media {
  position: relative;
  flex: 1;
  min-height: 0;
  display: grid;
  place-items: center;
}

/* the clip keeps its own aspect: height-fit and centred, so 4:3 clips show
   in full (no more top/bottom loss); if a wider clip (go, 16:9) outgrows
   the ticket it crops the SIDES via cover, never the top and bottom */
.ticket__media video {
  display: block;
  height: 100%;
  width: auto;
  max-width: 100%;
  object-fit: cover;
  border-radius: 8px;
}

/* left-hand side, +20% (Tom 2026-07-29) — bottom-left corner sits over
   ground/crowd in all four clips, clear of the subjects.
   Height-driven with a % cap (2026-08-05 edits doc): the phone lives inside
   .ticket__media, and on squat viewports the media box shrinks while a
   width-driven phone wouldn't — it escaped upward over the title/caption.
   The 88% ceiling pins it inside the media box at every viewport shape.
   (272px / 27.2vw / 392px = the old width clamp x the 680:1403 aspect.)

   Since 2026-08-05 the phone is a CSS ink bezel around the MVP screen
   recordings (390x844 captures, no frame baked in) — same drawn-device
   language as the for-venues dashboard mockups. object-fit: contain means
   any aspect slack shows as a sliver of bezel, never a cropped app UI. */
.ticket__phone {
  position: absolute;
  left: clamp(10px, 1.4vw, 18px);
  bottom: clamp(8px, 1.2vw, 14px);
  height: min(clamp(272px, 27.2vw, 392px), 88%);
  aspect-ratio: 390 / 844;
  display: block;
  background: var(--ink);
  border-radius: clamp(20px, 2.2vw, 32px);
  padding: clamp(5px, 0.55vw, 8px);
  filter: drop-shadow(0 14px 26px rgba(38, 37, 31, 0.35));
}

.ticket__phone video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  border-radius: clamp(15px, 1.7vw, 25px);
}

/* mobile-only scroll cue — see the ≤900px block */
.how__cue { display: none; }

/* ---- section 3 · queue banners ---- */

.s3 {
  /* ONE pin hosts the tape AND the "no more" story: tape plays over the first
     250svh of pin scroll, the story over the next 330svh. Total height =
     250 + 330 + 100 (sticky viewport) — the page ends exactly as the finale
     completes, so the closing frame stays pinned at the bottom. */
  height: 680svh;
  position: relative;
}

/* The iOS chrome-gap guard, SCOPED to the dark act. The pin is 100svh; when
   Safari retracts its chrome the viewport grows past that and the strip
   behind the pin shows .s3's background. A blanket #383838 here (launch day)
   fixed the strip but darkened the whole section — and s3's first act, the
   tape plus "we're putting an end to physical queues", plays over the .s2
   PAPER sheet showing through this transparent box (founder, 2026-08-07).
   So the dark only lands while the dark stage IS the backdrop. The class is
   owned by a ScrollTrigger in main.js, not by theme-dark: theme-dark is
   timed to the NAV (it flips at 2.2 of the tape, ~75px after the expanding
   ribbon has covered the screen), and a paper strip under an already-black
   screen is exactly the flash this guard exists to prevent. */
.s3.is-stage-dark { background: #383838; }

.s3__pin {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
}

.s3__statement {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  min-height: 0;
}

.banner {
  position: absolute;
  left: -15vw;
  width: 130vw;
  height: 31.2svh;
  overflow: hidden;
  will-change: transform;
}

/* tops overlap the ribbons on each other so together they fully block the
   statement (the opposing rotations open gaps at the sides if spaced apart) */
/* Figure colour is a LEGIBILITY value, not just a tint. The dark ribbon's
   #918C81 reads at 3.50:1; the green ribbons' old #B2C9A2 read at 1.57:1 —
   and 1.29:1 for the alternate figures the opacity rule fades (see
   .banner__figure in base.css). On a phone that is below the threshold where
   anything resolves, so the green bands looked EMPTY while the dark one
   looked full, and which figures happened to be legible kept changing as the
   three tracks (46s / 58s reversed / 52s) drifted out of phase. That is the
   "silhouettes populate a random banner, and it changes mid-animation and
   between loads" report of 2026-08-09 — the figures were always there, they
   just could not be seen.

   --cream on green is 2.38:1, and it is what the footer and menu-overlay
   marquees have always used for exactly this artwork; the s3 ribbons were
   the odd ones out. (Cream-on-green sits at 2.38 site-wide — the
   brand-level review of that number is still an open item.) */
.banner--a { top: 16svh; transform: rotate(10deg); background: var(--green); color: var(--cream); }
.banner--b { top: 35.5svh; transform: rotate(-9deg); background: var(--dark); color: #918C81; }
.banner--c { top: 53.5svh; transform: rotate(12deg); background: var(--green); color: var(--cream); }

.banner--b .banner__track { animation-duration: 58s; animation-direction: reverse; }
.banner--c .banner__track { animation-duration: 52s; }

/* the logo's wake: same colour as the ribbon, wipes the figures away
   (lives on the dark strip — the queue is gone before it becomes the black section) */
.banner__eraser {
  position: absolute;
  inset: 0;
  background: var(--dark);
  transform: scaleX(0);
  transform-origin: left center;
}

.banner__logo {
  position: absolute;
  top: 50%;
  left: 0;
  width: 14svh;
  height: auto;
  transform: translate(-120%, -50%);
  will-change: transform;
}

/* ---- "no more" story (lives inside the .s3 pin) ----
   Geometry from the design frames (1920x1080): bg = the expanded #383838
   ribbon, character block on the left (~x177-590, v-centered), line starts
   at x912 (47.5vw), cream. */

.s4__char {
  position: absolute;
  top: 50%;
  width: auto;
  z-index: 1;
  /* vertical centering via GSAP yPercent so scroll-driven x/y stay composable */
  will-change: transform, opacity;
}

/* every character matches the first one's height (Tom: uniform sizing); the
   vw term caps width on narrower viewports so nothing collides with the line.
   Since 2026-08-05 time/hassle are CANVASES painting their clips (see the
   couple's maths note below - same scheme: `left` anchors the old artwork's
   horizontal centre, main.js xPercent centres the drawn subject on it, the
   height rescale keeps the subject at the old artwork's size). Subject
   fractions measured from the processed clips. */
.s4__char--time {
  left: calc(13vw + 0.307 * min(56svh, 55vw));
  height: calc(min(56svh, 55vw) / 0.95);
}
.s4__char--hassle {
  left: calc(9vw + 0.1985 * min(56svh, 85vw));
  height: calc(min(56svh, 85vw) / 0.934);
}

/* the queue GROUP: static blacked-out people (the old png minus the guy)
   with the animated hawaiian-guy clip aligned over his old slot - the
   container keeps the old png's exact geometry so s4Render moves the whole
   scene as one. Guy alignment derived from the png guy bbox (x 72.95-99.95%,
   feet 99.9%, cx 86.43%) vs the clip's subject (x 60.97-82.08% of the frame,
   h 89.2%, feet 94.4%, cx 71.5%). */
.s4__char--queue {
  left: 3vw;
  height: min(56svh, 36vw);
  aspect-ratio: 2200 / 1875;
}

/* the static silhouettes: the guy's clip paints in front of them (see below) */
.s4__queue-people {
  display: block;
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
}

/* the guy rides IN FRONT of the queue (Tom: he was hidden behind it).
   Both halves are load-bearing:
   - `left` was anchored on a mis-measured subject centre (77.5% of the frame
     instead of the real 71.5%), drawing him 7.6% of the group width left of
     his slot, standing inside the last figure. -4.6% puts him back on the old
     png's spot, clear of the queue — the people's pixels stop at 69.2%.
   - the clip is what makes the z-order safe: the canvas paints a flat #383838
     matte across its whole rect, so unclipped on top it would erase every
     silhouette it covers. Cropped to the guy's union bbox across all frames
     (+ ~2% margin, so the tapping foot never reaches an edge) the only matte
     left in front sits on bare stage, where it is invisible by construction.
     Re-cut the clip -> re-measure the bbox and redo these four insets. */
.s4__queue-guy {
  position: absolute;
  z-index: 2;
  left: -4.6%;
  top: -5.8%;
  height: 112%;
  aspect-ratio: 1440 / 1080;
  clip-path: inset(3% 16.5% 3.5% 59.4%);
}

/* the finale couple is a CANVAS painting the walking clip (16:9 frame with
   the pair centred at 80.3% of frame height on a baked #383838 matte, which
   is invisible on the stage - .s3__pin clips the overhang). The maths keep
   the COUPLE at the exact size/spot of the old s4-couple.png (aspect 0.777):
   `left` is the old couple's horizontal centre, main.js sets xPercent -50.5
   (subject centre is 50.5% across the clip), and the height rescales the old
   value so the drawn pair matches it. Change all three together. */
.s4__char--you {
  left: calc(10vw + 0.389 * min(56svh, 45vw));
  height: calc(min(56svh, 45vw) / 0.803);
}

/* off-screen decode source for the finale canvas - never displayed */
/* The off-screen source videos the character canvases are painted from.

   They are parked IN the pinned stage rather than at left: -9999px, and at
   opacity 0.01 rather than 0, on purpose. WebKit only lets a muted inline
   video play while it is actually rendered, and it pauses one that stops
   being visible: `left: -9999px` inside `.s3__pin`'s `overflow: hidden` is
   clipped away entirely, and `opacity: 0` is the other half of the same
   test. That is why the characters loaded but sat frozen on desktop Safari
   (reported 2026-08-09) — the canvases only ever got the one `loadeddata`
   frame, because the feeds behind them were never allowed to run.

   2px square in the stage's top-left corner, under everything, at 1%
   opacity: rendered as far as the media policy is concerned, invisible as
   far as anyone looking at the page is concerned. Do not "tidy" this back to
   an off-screen offset or a plain opacity: 0. */
.s4__feed {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 0;
  width: 2px;
  height: 2px;
  opacity: 0.01;
  pointer-events: none;
}

.s4__nomore,
.s4__final {
  position: absolute;
  left: 47.5vw; /* 912/1920 */
  top: 50%;
  z-index: 2; /* above the characters while they travel */
  font-weight: 700;
  /* 4.15vw measured from the frame SVGs: "just you, living a little more."
     spans 917.7px @1920 (ends 95.4vw), "…standing in line." spans 832.4px */
  font-size: 4.15vw;
  line-height: 0.9;
  letter-spacing: -0.05em;
  white-space: nowrap;
  color: var(--cream);
  will-change: transform, opacity;
}

/* "no more" is the fixed anchor; the tail words roll through the mask
   (waiting words sit below the clip — the dots carry the wayfinding) */
.s4__nomore { display: flex; align-items: flex-start; }

.s4__mask {
  position: relative;
  display: block;
  width: 29vw;    /* fits the longest word, "standing in line." (~27.5vw) */
  height: 1.12em; /* single line; room for descenders inside the clip */
  overflow: hidden;
}

.s4__word {
  position: absolute;
  top: 0;
  left: 0;
  white-space: nowrap;
  will-change: transform;
}

.s4__final { visibility: hidden; } /* revealed by the finale beat */

/* wayfinding dots — vertical column on the right edge, level with the line;
   one per beat, filled as you advance; the finale's dot is green */
.s4__dots {
  position: absolute;
  right: 1.6vw; /* hugs the edge — the finale line ends at 95.4vw (design) */
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  gap: 12px;
  z-index: 2;
  visibility: hidden; /* faded in with pane 1 by the render function */
}

.s4__dots span {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  border: 1.5px solid rgba(243, 236, 218, 0.4);
  transition: background 0.3s ease, border-color 0.3s ease, transform 0.3s ease;
}

.s4__dots span.is-active {
  background: var(--cream);
  border-color: var(--cream);
  transform: scale(1.2);
}

.s4__dots span:last-child.is-active {
  background: var(--green);
  border-color: var(--green);
}

/* ---- mobile ---- */

@media (max-width: 900px) {
  /* The run-in to the rail (founder 2026-08-07: "the gap after here's how it
     works is too big" — halve it). MEASURED at 390x844: 235px of bare paper
     between the sub's baseline and the first ticket, and it splits in two.
     116px belongs to this block — the sub's 9svh margin does NOT count
     toward the grid's `place-content: center`, so it hangs BELOW the centred
     rows and the 64svh min-height's slack lands under it as well. The other
     119px is the pin's own top padding plus the rail centring inside it, and
     that half stays: it is the rail sitting clear of the fixed nav (56px at
     this width), not decoration. So the block goes content-height with an
     explicit run-in instead: 235px -> 127px, and the statement's type rides
     78px higher, which also shortens the bare-paper stare the mobile handoff
     hands over into (see the dissolve note in main.js). Desktop keeps the
     64svh screen — this is inside the mobile rail's own breakpoint. */
  .statement--first { min-height: 0; padding-top: 16svh; padding-bottom: 1svh; }

  /* the ticket rail turns vertical: stubs are slim horizontal bars, the open
     ticket takes the remaining height (same flex-basis spring, column axis).
     Replaces Hasan's photo-on-top mobile frames — flag it to him. */
  .how { height: 300svh; }
  .how__pin {
    padding: 9svh 5vw 8svh; /* clears the fixed nav; room for the cue below */
  }
  .rail {
    flex-direction: column;
    width: 100%;
    height: min(72svh, 640px);
    transform: rotate(-1deg);
  }
  .ticket { flex-basis: 54px; }
  .ticket:first-child { border-radius: 14px 14px 0 0; }
  .ticket:last-child { border-radius: 0 0 14px 14px; }
  .ticket + .ticket { border-left: 2px solid var(--ink); border-top: none; }
  .ticket + .ticket::before {
    top: 0;
    bottom: auto;
    left: 12px;
    right: 12px;
    width: auto;
    height: 2px;
    background: repeating-linear-gradient(to right, rgba(38, 37, 31, 0.55) 0 7px, transparent 7px 15px);
  }
  .ticket__hole {
    top: 50%;
    left: auto;
    right: 14px;
    width: 18px;
    height: 18px;
    transform: translateY(-50%);
  }
  .ticket__stubword {
    left: 18px;
    top: 50%;
    bottom: auto;
    transform: translateY(-50%);
    writing-mode: horizontal-tb;
    font-size: 22px;
  }
  .ticket__open { padding: 14px 16px 16px; }
  .ticket__head {
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    margin-bottom: 10px;
    white-space: normal;
  }
  .ticket__word { font-size: 11vw; }
  .ticket__cap {
    font-size: 16px;
    overflow: visible;
    text-overflow: clip;
  }
  /* like the previous approved mobile design: the photo only, no phone mockup */
  .ticket__phone { display: none; }

  /* scroll cue: hand-drawn ink arrow + label, bottom centre of the pin;
     fades away once the last step (go) is reached */
  .how__cue {
    display: flex;
    position: absolute;
    left: 50%;
    bottom: 3.5svh;
    transform: translateX(-50%);
    flex-direction: column;
    align-items: center;
    gap: 4px;
    opacity: 0.8;
    transition: opacity 0.4s ease;
    pointer-events: none;
  }
  .how__cue img { width: 15px; height: auto; }
  @media (prefers-reduced-motion: no-preference) {
    .how__cue img { animation: cue-bob 1.1s ease-in-out infinite alternate; }
  }
  .how--last .how__cue { opacity: 0; }

  /* the autoplay stand-in sits BELOW the scroll cue on phones - the cue is
     122px off the fold here, so the desktop 48% would land on it */
  .screen-play { bottom: 40%; }

  /* section 4: character up top, line beneath (invented fallback).
     uniform height; margin-left = -(height * aspect / 2) so each image sits
     centered.

     The character and the line are ONE composition and the pair is centred
     as a group on the stage: --s4char is the character's height, --s4line
     the line block's (the 0.9em anchor plus the mask's 1.12em, at
     font-size 8.5vw) and --s4gap the air between them, and both `top`s are
     derived from the three so the group stays centred whichever term of the
     min() wins. A flat `top` per element does not survive that min().

     Centring is against the pin's own 100svh — the SMALL viewport — so the
     group cannot ride under an expanded iOS toolbar; where innerHeight is
     larger than svh the spare band sits below the group, not through it.

     The vw cap is the desktop collision idiom carried to phones: at a flat
     34svh the queue artwork measures 87vw wide at 402, which leaves the
     wayfinding dots no gutter to sit in (they landed on the hawaiian guy). */
  .s4__char--time,
  .s4__char--hassle,
  .s4__char--queue,
  .s4__char--you,
  .s4__nomore,
  .s4__final {
    --s4char: min(34svh, 66vw);
    --s4line: 17.2vw;
    --s4gap: 8svh;
    --s4mid: 50%;
  }

  /* Short stages: the consent banner owns the bottom quarter of a 375x667
     (157px of it), and a group centred on the geometric middle runs into it
     — measured 3.8px of clearance at 375x667 and a touch at 360x640, where
     the pre-fix layout had 24px and 16px. The pair tightens and rides a
     little above the middle, which is where the optical centre sits anyway
     once the nav has the top and the banner has the bottom. Still inside the
     +-4% the composition is judged on. 320x568 cannot fit this line above
     the banner at any centring — it overlapped before this fix too. */
  @media (max-height: 720px) {
    .s4__char--time,
    .s4__char--hassle,
    .s4__char--queue,
    .s4__char--you,
    .s4__nomore,
    .s4__final { --s4gap: 6svh; --s4mid: 47.5%; }
  }
  .s4__char--time,
  .s4__char--hassle,
  .s4__char--queue,
  .s4__char--you {
    left: 50%;
    height: var(--s4char);
    top: calc(var(--s4mid) - (var(--s4gap) + var(--s4line)) / 2);
  }
  /* time/hassle canvases: xPercent (main.js) centres the drawn subject on
     left 50%, so no margin-left; heights rescaled like the desktop rules */
  .s4__char--time   { margin-left: 0; height: calc(var(--s4char) / 0.95); }
  .s4__char--hassle { margin-left: 0; height: calc(var(--s4char) / 0.934); }
  .s4__char--queue  { margin-left: calc(var(--s4char) * -0.5867); }   /* aspect 1.173 */
  /* the couple canvas: xPercent -50.5 (main.js) centres the drawn pair on
     left 50%, so no margin-left; height rescaled like the desktop rule so
     the pair reads at the same 34svh as the other characters */
  .s4__char--you    { margin-left: 0; height: calc(var(--s4char) / 0.803); }
  .s4__nomore,
  .s4__final {
    left: 8vw;
    right: 8vw;
    top: calc(var(--s4mid) + (var(--s4gap) + var(--s4char)) / 2);
    font-size: 8.5vw;
    text-align: center;
  }
  /* A landscape phone sits in this block too, and 8.5vw makes its line block
     37% of the stage there (150px at 874x402) against a 34svh character:
     centring that pair as a group drives the character up under the nav and
     into the line. Landscape keeps the placement it has always had — the
     heights are identical either way, since the 66vw cap cannot bind when vw
     is the long edge. */
  @media (orientation: landscape) {
    .s4__char--time,
    .s4__char--hassle,
    .s4__char--queue,
    .s4__char--you { top: 32%; }
    .s4__nomore,
    .s4__final { top: 68%; }
  }
  .s4__final { white-space: normal; }
  .s4__nomore { flex-direction: column; align-items: center; }
  .s4__mask { width: 84vw; }
  .s4__word { left: 0; width: 100%; text-align: center; }
  /* the dots sit in the house gutter, not on the edge: `.sect`'s phone inset
     (7vw), which also lands the 9px column's centre on the 8vw edge the line
     is set to. env() keeps them off the notch in landscape. */
  .s4__dots { right: calc(7vw + env(safe-area-inset-right, 0px)); }
}

@media (max-width: 640px) {
  .ghost { font-size: 13vw; }
}

@keyframes cue-bob {
  from { transform: translateY(0); }
  to { transform: translateY(7px); }
}

/* ---------- Reduced motion ---------- */

@media (prefers-reduced-motion: reduce) {
  /* ticket states jump instead of fading (the tear and marker fill are
     snapped to 0/1 inside railRender under reduced motion) */
  .ticket__open,
  .ticket__hole,
  .ticket__stubword { transition: none; }
}
