Skip to content
All templates

Templates/Private Charter

For charter operators, private aviation and any brand whose product is a journey. Cool off-white and cloud grey carry the page, graphite is the type and the photographs bring the only colour. Hanken Grotesk sets everything, with Instrument Serif italic for one word per headline. The film is never played, the scroll picks its frames, and every section moves on one shared loop. Sound stays off until a visitor asks for it. With reduced motion the film becomes two held frames and the page stands still.

AviationFull page

The finished page to scroll through, switchable between desktop, tablet and mobile.

Source code

The page is one file, but it loads 21 images, 2 videos and 1 audio file (28 MB) from public/. Copy only the code and they are missing. The package holds both, at the right paths.

/*
 * Gateless, a private charter company (invented brand, demo copy and
 * figures; the request links open an email and send nothing).
 *
 * The page opens with one continuous camera move driven by the scroll:
 * inside the cabin, through the window, into cloud, out to the aircraft and
 * back until it is small in a wide sky. Then the film lets go of the
 * screen's edges and becomes a picture on the page that follows: how it
 * works (a jet taxiing from request to runway, seen from above), the fleet
 * with a sticky picture, a band where the words slide behind the aircraft,
 * an outside and inside cutaway, a cabin window that opens to the whole
 * runway, destinations seen through a row of windows, routes, the
 * experience, a last shot and the footer.
 *
 * Everything moves on one requestAnimationFrame loop and only writes a
 * style when its value changes; nothing goes through React state while the
 * page scrolls. Sound is off until asked for and then built from filtered
 * noise in the browser. Under reduced motion the film is two held frames
 * and every section stands still.
 *
 * Only react and lucide-react. Fonts: Hanken Grotesk and Instrument Serif
 * italic.
 */

import {
  Fragment,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type MouseEvent,
  type ReactNode,
  type RefObject,
} from "react";
import {
  Armchair,
  ArrowDown,
  ArrowUp,
  ArrowUpRight,
  Building2,
  Clock,
  Globe,
  Headset,
  Mail,
  MapPin,
  Plane,
  PlaneTakeoff,
  Route,
  Ruler,
  Sofa,
  Timer,
  TowerControl,
  Volume2,
  VolumeX,
  type LucideIcon,
} from "lucide-react";

const BASE = "/templates/private-charter";

/* ——————————————— the loop ——————————————— */

/* One requestAnimationFrame loop for the whole page.

   Every animated part (the film, each section) registers a scene. A scene
   is only ticked while an IntersectionObserver says it is near the
   viewport. Each tick reads all geometry first and only then writes, so
   no write ever forces a layout for the next read. When no scene asks for
   another frame the loop sleeps until the page scrolls, resizes or a scene
   kicks it. */

type Scene = {
  /** Read geometry. Called for every active scene before any write. */
  read?: () => void;
  /** Write styles. Return true to be called again next frame. */
  write: (now: number, dt: number) => boolean;
  /** Told when the scene comes near the viewport or leaves it. */
  onActive?: (active: boolean) => void;
};

type Entry = { scene: Scene; active: boolean };

const entries = new Set<Entry>();
let raf = 0;
let last = 0;
let listening = false;

function tick(now: number) {
  raf = 0;
  const dt = last ? Math.min(0.1, (now - last) / 1000) : 1 / 60;
  last = now;
  let again = false;
  for (const e of entries) if (e.active) e.scene.read?.();
  for (const e of entries) if (e.active && e.scene.write(now, dt)) again = true;
  if (again) raf = requestAnimationFrame(tick);
  else last = 0;
}

function wake() {
  if (raf) return;
  for (const e of entries) {
    if (e.active) {
      raf = requestAnimationFrame(tick);
      return;
    }
  }
}

function listen() {
  if (listening) return;
  listening = true;
  window.addEventListener("scroll", wake, { passive: true });
  window.addEventListener("resize", wake);
}

/** Registers a scene, active while `el` is within `margin` of the viewport. */
function addScene(el: Element, scene: Scene, margin = "50% 0px"): () => void {
  listen();
  const entry: Entry = { scene, active: false };
  entries.add(entry);
  const io = new IntersectionObserver(
    ([e]) => {
      entry.active = !!e?.isIntersecting;
      scene.onActive?.(entry.active);
      if (entry.active) wake();
    },
    { rootMargin: margin },
  );
  io.observe(el);
  return () => {
    io.disconnect();
    entries.delete(entry);
  };
}

/* Style writes only when the value changes. */
const cache = new WeakMap<HTMLElement | SVGElement, Record<string, string>>();
function put(el: HTMLElement | SVGElement, prop: string, value: string) {
  let c = cache.get(el);
  if (!c) cache.set(el, (c = {}));
  if (c[prop] === value) return;
  c[prop] = value;
  el.style.setProperty(prop, value);
}

/* Time-based smoothing: the same feel at 60 Hz and 120 Hz. */
function follow(current: number, target: number, dt: number, tau: number) {
  const next = current + (target - current) * (1 - Math.exp(-dt / tau));
  return Math.abs(target - next) < 2e-5 ? target : next;
}

/* Static film grain: generated once, one texel per device pixel, never
   animated. Plain alpha (no blend mode), so the compositor only stacks it
   over the video layer and nothing is repainted per frame. */
function grainTile(size = 128, strength = 9): string {
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = size;
  const ctx = canvas.getContext("2d");
  if (!ctx) return "";
  const img = ctx.createImageData(size, size);
  const d = img.data;
  for (let i = 0; i < size * size; i++) {
    const v = Math.random() < 0.5 ? 0 : 255;
    d[i * 4] = d[i * 4 + 1] = d[i * 4 + 2] = v;
    d[i * 4 + 3] = Math.random() * strength; // 9: ≤ 3.5 %, ~1.8 % on average
  }
  ctx.putImageData(img, 0, 0);
  return canvas.toDataURL("image/png");
}

/* Top edge of the aircraft in the film, measured frame by frame from the
   source (scripted edge scan against the sky, 3-column median). Used to
   clip "Distance changes everything." so it rises from behind the
   fuselage. Values are fractions of the frame (0 = top). */

const SIL_X = [
  0.3, 0.325, 0.35, 0.375, 0.4, 0.425, 0.45, 0.475, 0.5, 0.525, 0.55, 0.575, 0.6, 0.625, 0.65,
  0.675, 0.7, 0.725, 0.75, 0.775, 0.8, 0.825, 0.85, 0.875, 0.9, 0.925, 0.95,
];
const SIL_T0 = 11.25;
const SIL_DT = 0.125;
const SIL_Y: number[][] = [
  [
    0.315, 0.307, 0.304, 0.304, 0.311, 0.322, 0.322, 0.322, 0.319, 0.315, 0.315, 0.311, 0.311,
    0.307, 0.304, 0.304, 0.3, 0.3, 0.289, 0.289, 0.289, 0.289, 0.285, 0.278, 0.278, 0.281, 0.281,
  ], // 11.250 s
  [
    0.33, 0.322, 0.322, 0.322, 0.337, 0.337, 0.337, 0.333, 0.33, 0.33, 0.326, 0.322, 0.322, 0.319,
    0.315, 0.311, 0.311, 0.304, 0.304, 0.304, 0.296, 0.296, 0.296, 0.296, 0.296, 0.293, 0.293,
  ], // 11.375 s
  [
    0.341, 0.333, 0.333, 0.333, 0.348, 0.348, 0.344, 0.341, 0.341, 0.337, 0.333, 0.333, 0.33, 0.326,
    0.326, 0.319, 0.315, 0.315, 0.311, 0.311, 0.307, 0.307, 0.307, 0.304, 0.304, 0.304, 0.304,
  ], // 11.500 s
  [
    0.352, 0.344, 0.344, 0.344, 0.356, 0.356, 0.356, 0.352, 0.348, 0.348, 0.344, 0.341, 0.337,
    0.333, 0.326, 0.33, 0.326, 0.326, 0.322, 0.319, 0.319, 0.319, 0.315, 0.315, 0.319, 0.322, 0.333,
  ], // 11.625 s
  [
    0.367, 0.359, 0.356, 0.356, 0.367, 0.367, 0.363, 0.363, 0.359, 0.356, 0.352, 0.348, 0.348,
    0.344, 0.341, 0.337, 0.337, 0.333, 0.333, 0.33, 0.33, 0.33, 0.33, 0.333, 0.344, 0.363, 0.389,
  ], // 11.750 s
  [
    0.378, 0.367, 0.367, 0.367, 0.378, 0.378, 0.374, 0.37, 0.367, 0.363, 0.363, 0.359, 0.348, 0.348,
    0.348, 0.344, 0.341, 0.341, 0.341, 0.337, 0.337, 0.341, 0.348, 0.363, 0.389, 0.426, 0.441,
  ], // 11.875 s
  [
    0.389, 0.378, 0.378, 0.381, 0.385, 0.385, 0.381, 0.378, 0.374, 0.374, 0.37, 0.363, 0.363, 0.363,
    0.359, 0.356, 0.352, 0.348, 0.348, 0.348, 0.352, 0.363, 0.381, 0.411, 0.437, 0.459, 0.493,
  ], // 12.000 s
  [
    0.4, 0.389, 0.385, 0.385, 0.393, 0.393, 0.389, 0.385, 0.385, 0.381, 0.378, 0.374, 0.367, 0.367,
    0.363, 0.363, 0.359, 0.359, 0.359, 0.363, 0.37, 0.396, 0.426, 0.448, 0.481, 0.663, 0.685,
  ], // 12.125 s
  [
    0.411, 0.4, 0.393, 0.393, 0.4, 0.4, 0.396, 0.393, 0.389, 0.385, 0.381, 0.378, 0.378, 0.374,
    0.37, 0.37, 0.367, 0.367, 0.374, 0.378, 0.404, 0.441, 0.456, 0.481, 0.67, 0.67, 0.696,
  ], // 12.250 s
  [
    0.419, 0.411, 0.404, 0.404, 0.411, 0.411, 0.407, 0.404, 0.4, 0.396, 0.385, 0.381, 0.385, 0.381,
    0.381, 0.378, 0.378, 0.378, 0.389, 0.415, 0.444, 0.47, 0.507, 0.681, 0.681, 0.681, 0.681,
  ], // 12.375 s
  [
    0.426, 0.422, 0.411, 0.411, 0.415, 0.415, 0.411, 0.411, 0.407, 0.404, 0.4, 0.393, 0.393, 0.389,
    0.389, 0.389, 0.389, 0.393, 0.411, 0.452, 0.467, 0.504, 0.67, 0.689, 0.689, 0.689, 0.704,
  ], // 12.500 s
  [
    0.43, 0.43, 0.419, 0.419, 0.422, 0.422, 0.419, 0.415, 0.411, 0.407, 0.407, 0.404, 0.396, 0.396,
    0.393, 0.393, 0.396, 0.407, 0.437, 0.463, 0.5, 0.674, 0.693, 0.693, 0.7, 0.7, 0.7,
  ], // 12.625 s
  [
    0.437, 0.43, 0.426, 0.426, 0.426, 0.426, 0.426, 0.422, 0.419, 0.415, 0.411, 0.407, 0.404, 0.404,
    0.4, 0.4, 0.404, 0.426, 0.459, 0.485, 0.704, 0.707, 0.707, 0.707, 0.707, 0.707, 0.707,
  ], // 12.750 s
  [
    0.441, 0.437, 0.433, 0.43, 0.43, 0.43, 0.43, 0.426, 0.422, 0.419, 0.415, 0.407, 0.407, 0.407,
    0.407, 0.407, 0.419, 0.448, 0.474, 0.504, 0.711, 0.715, 0.711, 0.711, 0.711, 0.711, 0.715,
  ], // 12.875 s
  [
    0.441, 0.441, 0.441, 0.433, 0.433, 0.433, 0.433, 0.43, 0.426, 0.419, 0.419, 0.419, 0.415, 0.411,
    0.411, 0.415, 0.433, 0.463, 0.489, 0.693, 0.711, 0.711, 0.711, 0.715, 0.715, 0.719, 0.719,
  ], // 13.000 s
  [
    0.444, 0.444, 0.444, 0.437, 0.437, 0.437, 0.437, 0.433, 0.43, 0.419, 0.419, 0.419, 0.419, 0.415,
    0.415, 0.422, 0.448, 0.478, 0.507, 0.696, 0.711, 0.722, 0.722, 0.722, 0.722, 0.722, 0.722,
  ], // 13.125 s
  [
    0.444, 0.444, 0.444, 0.441, 0.441, 0.441, 0.437, 0.433, 0.433, 0.422, 0.419, 0.419, 0.419,
    0.419, 0.419, 0.43, 0.463, 0.489, 0.7, 0.715, 0.726, 0.73, 0.73, 0.73, 0.726, 0.726, 0.726,
  ], // 13.250 s
  [
    0.448, 0.448, 0.448, 0.441, 0.441, 0.441, 0.441, 0.437, 0.433, 0.426, 0.426, 0.426, 0.422,
    0.422, 0.422, 0.444, 0.474, 0.704, 0.719, 0.719, 0.73, 0.73, 0.733, 0.711, 0.73, 0.73, 0.73,
  ], // 13.375 s
  [
    0.378, 0.378, 0.444, 0.444, 0.444, 0.441, 0.441, 0.437, 0.433, 0.43, 0.426, 0.426, 0.422, 0.422,
    0.43, 0.456, 0.485, 0.707, 0.707, 0.707, 0.73, 0.73, 0.733, 0.726, 0.73, 0.73, 0.733,
  ], // 13.500 s
  [
    0.363, 0.363, 0.448, 0.448, 0.448, 0.444, 0.444, 0.441, 0.437, 0.433, 0.43, 0.426, 0.426, 0.426,
    0.433, 0.467, 0.496, 0.719, 0.719, 0.719, 0.715, 0.726, 0.726, 0.726, 0.73, 0.73, 0.733,
  ], // 13.625 s
  [
    0.367, 0.367, 0.448, 0.448, 0.448, 0.444, 0.444, 0.441, 0.437, 0.433, 0.433, 0.43, 0.426, 0.426,
    0.444, 0.474, 0.722, 0.722, 0.719, 0.719, 0.711, 0.73, 0.73, 0.73, 0.737, 0.737, 0.737,
  ], // 13.750 s
  [
    0.367, 0.367, 0.452, 0.452, 0.448, 0.444, 0.444, 0.441, 0.437, 0.437, 0.433, 0.43, 0.43, 0.43,
    0.452, 0.481, 0.722, 0.722, 0.719, 0.719, 0.711, 0.733, 0.733, 0.733, 0.737, 0.737, 0.737,
  ], // 13.875 s
  [
    0.37, 0.37, 0.452, 0.452, 0.448, 0.444, 0.444, 0.444, 0.441, 0.437, 0.433, 0.43, 0.43, 0.433,
    0.459, 0.489, 0.722, 0.722, 0.719, 0.719, 0.719, 0.726, 0.741, 0.741, 0.741, 0.737, 0.741,
  ], // 14.000 s
  [
    0.37, 0.37, 0.452, 0.452, 0.452, 0.448, 0.448, 0.444, 0.441, 0.437, 0.433, 0.433, 0.433, 0.437,
    0.467, 0.715, 0.722, 0.722, 0.715, 0.715, 0.715, 0.726, 0.737, 0.737, 0.726, 0.726, 0.737,
  ], // 14.125 s
  [
    0.374, 0.374, 0.452, 0.452, 0.452, 0.448, 0.448, 0.444, 0.441, 0.437, 0.433, 0.433, 0.433,
    0.444, 0.474, 0.715, 0.715, 0.715, 0.715, 0.715, 0.715, 0.719, 0.737, 0.737, 0.726, 0.726,
    0.737,
  ], // 14.250 s
  [
    0.374, 0.374, 0.456, 0.456, 0.452, 0.448, 0.448, 0.444, 0.441, 0.437, 0.437, 0.433, 0.433,
    0.452, 0.485, 0.707, 0.726, 0.726, 0.715, 0.715, 0.73, 0.73, 0.722, 0.722, 0.722, 0.726, 0.741,
  ], // 14.375 s
  [
    0.378, 0.378, 0.381, 0.448, 0.452, 0.448, 0.448, 0.448, 0.444, 0.441, 0.437, 0.437, 0.437,
    0.463, 0.493, 0.7, 0.73, 0.733, 0.73, 0.73, 0.73, 0.741, 0.741, 0.744, 0.722, 0.741, 0.741,
  ], // 14.500 s
  [
    0.378, 0.378, 0.452, 0.452, 0.452, 0.452, 0.452, 0.448, 0.444, 0.441, 0.437, 0.437, 0.441,
    0.467, 0.696, 0.707, 0.715, 0.73, 0.715, 0.73, 0.73, 0.741, 0.741, 0.722, 0.719, 0.719, 0.737,
  ], // 14.625 s
  [
    0.381, 0.381, 0.452, 0.452, 0.452, 0.452, 0.452, 0.448, 0.444, 0.441, 0.437, 0.437, 0.444,
    0.474, 0.685, 0.696, 0.715, 0.73, 0.715, 0.73, 0.73, 0.741, 0.741, 0.737, 0.722, 0.722, 0.737,
  ], // 14.750 s
];

/** Top edge (frame fraction) at source time t for every SIL_X column. */
function silhouetteAt(t: number, out: number[] = []): number[] {
  const f = Math.min(SIL_Y.length - 1, Math.max(0, (t - SIL_T0) / SIL_DT));
  const i = Math.min(SIL_Y.length - 2, Math.floor(f));
  const k = f - i;
  const a = SIL_Y[i];
  const b = SIL_Y[i + 1];
  for (let j = 0; j < SIL_X.length; j++) out[j] = a[j] + (b[j] - a[j]) * k;
  return out;
}

/* ——————————————— timeline ——————————————— */

/* The film and how the scroll drives it.

   1920 x 1080, H.264, 24 fps, 367 frames, 15.29 s. What is actually in it
   (checked frame by frame):

     0.00 – 3.00   cabin, seat and round windows, very slow drift forward
     3.00 – 5.75   push towards the big window, gathering pace
     5.75 – 7.30   through the glass: the frame slides away, cloud fills it
     7.30 – 8.55   whiteout, nothing but cloud
     8.55 – 10.40  the wing and engine come out of the cloud, very close
    10.40 – 11.20  breaks clear, still very close
    11.20 – 14.30  the long pull-back, aircraft shrinking
    14.30 – 15.29  the wide frame, a small aircraft and a lot of sky

   There is no exterior cold open in this file; it starts in the cabin. */

const FPS = 24;
const DURATION = 15.2917;
/* Held a frame short of the end: seeking onto the very last frame fires
   `ended` in some browsers and they then show frame 0. */
const LAST_FRAME = 365;
const HOLD_TIME = (LAST_FRAME + 0.5) / FPS; // 15.229 s

/* Scroll progress (0..1) → source seconds. The points are joined by a
   monotone cubic, so speed changes are curves, never corners, and the film
   can never run backwards while the page goes forwards. */
const DRIVE: ReadonlyArray<readonly [number, number]> = [
  [0.0, 0.0], //   cold open, first frame, title standing
  [0.05, 0.3], //  the title lifts as the cabin starts to drift
  [0.18, 2.85], // cabin, calm
  [0.275, 5.7], // window approach, gathering pace
  [0.335, 7.3], // through the glass into cloud
  [0.375, 8.55], // whiteout
  [0.44, 10.35], // the aircraft comes out of the cloud, very close
  [0.5, 11.2], //  clear, still very close
  [0.64, 12.7], // the long pull-back …
  [0.78, 14.1], // … sky taking over
  [0.88, 15.0], // the wide frame, slowing
  [0.94, 15.18], // almost still
  [1.0, HOLD_TIME], // held
];

/* Text beats, in scroll progress. `in` is the entrance, `out` the exit
   (always about 1.5× the entrance). Long stretches have no text at all. */
type BeatId = "cold" | "inside" | "distance" | "final";
type Beat = {
  in: readonly [number, number];
  out?: readonly [number, number];
  /** Lines marked data-rise come up from behind the aircraft (clipped). */
  rise?: boolean;
  /** For a rising beat: when its other lines (the label) fade in. */
  after?: readonly [number, number];
};

const BEATS: Record<BeatId, Beat> = {
  // On screen from load (time-based intro); leaves as the cabin starts moving.
  cold: { in: [-1, 0], out: [0.028, 0.075] },
  // A small label once inside, then nothing.
  inside: { in: [0.095, 0.125], out: [0.158, 0.203] },
  // No text from 0.203 to 0.548: window, cloud and the reveal carry it.
  // Then the words come up from behind the fuselage as the camera pulls
  // away (t ≈ 11.7–12.6 s), the label follows once they stand.
  distance: { in: [0.548, 0.632], rise: true, after: [0.612, 0.65], out: [0.73, 0.795] },
  // From the widest frame to the end.
  final: { in: [0.83, 0.905] },
};

/* Word and letter stagger inside a line, in progress units. */
const WORD_STAGGER = 0.0055;
const CHAR_STAGGER = 0.0011;

/* The rise: each line starts this far below its place (fraction of the
   film's height) and lines follow each other by `stagger` progress. */
const RISE = { depth: 0.32, stagger: 0.011 };

/* Delay between lines of one block, in progress units. */
const STAGGER = 0.0085;

/* The final frame keeps easing back by a hair so it never looks frozen:
   the film sits 1.8 % large throughout and settles to 1.0 over the hold,
   which continues the pull-back instead of reversing it. */
const SETTLE_SCALE = 0.018;
const SETTLE_RANGE: readonly [number, number] = [0.8, 1.0];

/* Narrow (portrait) screens see about a quarter of the frame's width, so
   the crop follows the action: the window first, then the aircraft. Source
   seconds → horizontal focus in the frame (0 = left edge, 1 = right). */
const FOCUS_X: ReadonlyArray<readonly [number, number]> = [
  [0.0, 0.7],
  [1.5, 0.68],
  [3.0, 0.63],
  [4.5, 0.57],
  [5.75, 0.52],
  [7.3, 0.5],
  [9.0, 0.5],
  [10.4, 0.47],
  [11.5, 0.5],
  [13.25, 0.51],
  /* The wide frame: a phone cannot hold the whole aircraft, so it keeps
     the nose and the sky ahead of it, not the tail. */
  [15.3, 0.53],
];

const clamp = (v: number, lo = 0, hi = 1) => (v < lo ? lo : v > hi ? hi : v);
const easeOut = (u: number) => 1 - Math.pow(1 - u, 3);
const smooth = (u: number) => u * u * (3 - 2 * u);

/* Monotone cubic Hermite interpolation (Fritsch–Carlson). */
function monotoneCurve(
  points: ReadonlyArray<readonly [number, number]>,
  endSlopeZero = false,
): (x: number) => number {
  const n = points.length;
  const xs = points.map((p) => p[0]);
  const ys = points.map((p) => p[1]);
  const d: number[] = [];
  for (let i = 0; i < n - 1; i++) d.push((ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i]));
  const m: number[] = new Array(n);
  m[0] = d[0];
  m[n - 1] = endSlopeZero ? 0 : d[n - 2];
  for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;
  for (let i = 0; i < n - 1; i++) {
    if (d[i] === 0) {
      m[i] = 0;
      m[i + 1] = 0;
      continue;
    }
    const a = m[i] / d[i];
    const b = m[i + 1] / d[i];
    const s = a * a + b * b;
    if (s > 9) {
      const t = 3 / Math.sqrt(s);
      m[i] = t * a * d[i];
      m[i + 1] = t * b * d[i];
    }
  }
  return (x: number) => {
    if (x <= xs[0]) return ys[0];
    if (x >= xs[n - 1]) return ys[n - 1];
    let i = 0;
    while (i < n - 2 && x > xs[i + 1]) i++;
    const h = xs[i + 1] - xs[i];
    const t = (x - xs[i]) / h;
    const t2 = t * t;
    const t3 = t2 * t;
    return (
      (2 * t3 - 3 * t2 + 1) * ys[i] +
      (t3 - 2 * t2 + t) * h * m[i] +
      (-2 * t3 + 3 * t2) * ys[i + 1] +
      (t3 - t2) * h * m[i + 1]
    );
  };
}

/* Piecewise-linear lookup, for sound levels. */
function linearCurve(points: ReadonlyArray<readonly [number, number]>) {
  return (x: number) => {
    if (x <= points[0][0]) return points[0][1];
    for (let i = 0; i < points.length - 1; i++) {
      const [x0, y0] = points[i];
      const [x1, y1] = points[i + 1];
      if (x <= x1) return y0 + ((x - x0) / (x1 - x0)) * (y1 - y0);
    }
    return points[points.length - 1][1];
  };
}

const driveTime = monotoneCurve(DRIVE, true);
const focusX = monotoneCurve(FOCUS_X);

/* ——————————————— sound ——————————————— */

/* Gateless — sound.

   Four looping layers, mixed from where the camera is in the film (source
   seconds), so the sound describes space and distance rather than scoring
   anything. No music, no hits.

     cabin        low cabin hum with the engines muffled through the hull
     windowAir    air at the glass; swells as the camera reaches the window
                  and is the "pressure" moment through the cloud
     wind         wide open-air wind outside
     engine       the aircraft's engine: muffled inside, close when it comes
                  out of the cloud, then further and duller as it pulls away
                  (distance = level down + high frequencies gone)

   Every layer is a quiet synthesised stand-in (filtered noise) unless a
   recording is named in SOUND_FILES. Nothing is built or fetched until the
   visitor switches sound on. A recording that is missing or does not decode
   falls back to the stand-in. */

type LayerId = "cabin" | "windowAir" | "wind" | "engine";

/* Recordings for the layers, looped. Leave a layer at null for its
   stand-in. */
const SOUND_FILES: Record<LayerId, string | null> = {
  cabin: null,
  windowAir: null,
  wind: null,
  engine: null,
};
const MASTER = 0.6;

/* Levels against source time (seconds). */
const MIX = {
  cabin: linearCurve([
    [0, 0.9],
    [4.5, 0.9],
    [6.4, 0.5],
    [7.5, 0.12],
    [8.5, 0],
  ]),
  windowAir: linearCurve([
    [0, 0],
    [2.8, 0.04],
    [5.0, 0.3],
    [6.5, 0.75],
    [7.5, 1],
    [8.5, 0.75],
    [10.4, 0.25],
    [12, 0.12],
    [15.3, 0.08],
  ]),
  wind: linearCurve([
    [0, 0],
    [6.6, 0],
    [7.6, 0.3],
    [9.0, 0.7],
    [11, 0.85],
    [15.3, 0.95],
  ]),
  engine: linearCurve([
    [0, 0.22],
    [5.5, 0.22],
    [7.3, 0.12],
    [8.6, 0.45],
    [10.4, 1],
    [11.2, 0.95],
    [13, 0.6],
    [15.3, 0.32],
  ]),
};
/* Engine low-pass (Hz): through the hull, then close, then far. */
const ENGINE_TONE = linearCurve([
  [0, 320],
  [5.5, 420],
  [7.3, 600],
  [8.6, 2500],
  [10.4, 7000],
  [11.2, 6000],
  [13, 2400],
  [15.3, 1100],
]);
/* Low-pass on everything outside (Hz): the cloud closes it down briefly. */
const PRESSURE = linearCurve([
  [0, 16000],
  [7.0, 16000],
  [7.6, 1100],
  [8.4, 800],
  [9.2, 5000],
  [10.0, 16000],
]);

type Layer = {
  gain: GainNode;
  source?: AudioBufferSourceNode;
  synth?: boolean;
};

class AirSound {
  private ctx: AudioContext | null = null;
  private master!: GainNode;
  private outside!: BiquadFilterNode;
  private engineTone!: BiquadFilterNode;
  private layers = {} as Record<LayerId, Layer>;
  private last = new Map<AudioParam, number>();
  private on = false;
  private audible = true;
  private t = 0;
  private suspendTimer = 0;

  get enabled() {
    return this.on;
  }

  /** Call from a click/tap handler (iOS needs the gesture). */
  async enable(t = this.t) {
    this.t = t;
    this.on = true;
    window.clearTimeout(this.suspendTimer);
    if (!this.ctx) this.build();
    const ctx = this.ctx!;
    this.update(t);
    this.applyMaster(0.9);
    if (ctx.state !== "running") await ctx.resume().catch(() => {});
  }

  disable() {
    this.on = false;
    this.applyMaster(0.18);
    window.clearTimeout(this.suspendTimer);
    this.suspendTimer = window.setTimeout(() => {
      if (!this.on) this.ctx?.suspend().catch(() => {});
    }, 900);
  }

  /** Off-screen or hidden tab: fade out without forgetting the switch. */
  setAudible(audible: boolean) {
    if (this.audible === audible) return;
    this.audible = audible;
    if (!this.ctx) return;
    if (audible && this.on) this.ctx.resume().catch(() => {});
    this.applyMaster(audible ? 0.6 : 0.15);
  }

  /** Source time of the frame on screen. Cheap; call every frame. */
  update(t: number) {
    this.t = t;
    if (!this.ctx || !this.on) return;
    for (const id of Object.keys(MIX) as LayerId[]) this.set(this.layers[id].gain.gain, MIX[id](t));
    this.set(this.engineTone.frequency, ENGINE_TONE(t), 0.2);
    this.set(this.outside.frequency, PRESSURE(t), 0.1);
  }

  dispose() {
    window.clearTimeout(this.suspendTimer);
    this.ctx?.close().catch(() => {});
    this.ctx = null;
  }

  private build() {
    const AC: typeof AudioContext =
      window.AudioContext ??
      (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
    const ctx = (this.ctx = new AC());
    this.master = ctx.createGain();
    this.master.gain.value = 0;
    this.master.connect(ctx.destination);

    this.outside = ctx.createBiquadFilter();
    this.outside.type = "lowpass";
    this.outside.Q.value = 0.5;
    this.outside.frequency.value = PRESSURE(this.t);
    this.outside.connect(this.master);

    this.engineTone = ctx.createBiquadFilter();
    this.engineTone.type = "lowpass";
    this.engineTone.Q.value = 0.4;
    this.engineTone.frequency.value = ENGINE_TONE(this.t);
    this.engineTone.connect(this.outside);

    const route: Record<LayerId, AudioNode> = {
      cabin: this.master,
      windowAir: this.outside,
      wind: this.outside,
      engine: this.engineTone,
    };
    for (const id of Object.keys(MIX) as LayerId[]) {
      const gain = ctx.createGain();
      gain.gain.value = MIX[id](this.t);
      gain.connect(route[id]);
      this.layers[id] = { gain };
      void this.load(id);
    }
  }

  private async load(id: LayerId) {
    const ctx = this.ctx!;
    let buffer: AudioBuffer | null = null;
    const file = SOUND_FILES[id];
    if (file)
      try {
        const res = await fetch(file);
        const type = res.headers.get("content-type") ?? "";
        // A dev server answers a missing file with its HTML page and a 200.
        if (res.ok && !type.includes("text/html")) {
          buffer = await ctx.decodeAudioData(await res.arrayBuffer());
        }
      } catch {
        buffer = null;
      }
    if (!this.ctx) return;
    const layer = this.layers[id];
    let input: AudioNode = layer.gain;
    if (!buffer) {
      const stand = synthStandIn(ctx, id);
      buffer = stand.buffer;
      input = stand.chain(layer.gain);
      layer.synth = true;
    }
    const src = ctx.createBufferSource();
    src.buffer = buffer;
    src.loop = true;
    src.connect(input);
    // Different start offsets so the loops never line up.
    src.start(0, Math.random() * Math.max(0, buffer.duration - 0.1));
    layer.source = src;
  }

  private applyMaster(tau: number) {
    if (!this.ctx) return;
    const target = this.on && this.audible ? MASTER : 0;
    this.master.gain.cancelScheduledValues(this.ctx.currentTime);
    this.master.gain.setTargetAtTime(target, this.ctx.currentTime, tau);
  }

  private set(param: AudioParam, value: number, tau = 0.12) {
    const prev = this.last.get(param);
    if (prev !== undefined && Math.abs(prev - value) < Math.max(1e-3, Math.abs(value) * 0.004))
      return;
    this.last.set(param, value);
    param.setTargetAtTime(value, this.ctx!.currentTime, tau);
  }
}

/* ——— stand-ins ——— */

function synthStandIn(ctx: AudioContext, id: LayerId) {
  const seconds = 6;
  const brown = id === "cabin" || id === "engine";
  const buffer = loopableNoise(ctx, seconds, brown ? "brown" : "pink");
  const chain = (out: AudioNode): AudioNode => {
    const makeup = ctx.createGain();
    makeup.connect(out);
    const f = ctx.createBiquadFilter();
    f.connect(makeup);
    if (id === "cabin") {
      f.type = "lowpass";
      f.frequency.value = 190;
      f.Q.value = 0.7;
      makeup.gain.value = 1.3;
    } else if (id === "windowAir") {
      f.type = "bandpass";
      f.frequency.value = 1400;
      f.Q.value = 0.5;
      makeup.gain.value = 0.45;
    } else if (id === "wind") {
      f.type = "bandpass";
      f.frequency.value = 420;
      f.Q.value = 0.35;
      makeup.gain.value = 1.0;
      // Slow gusts.
      const lfo = ctx.createOscillator();
      const depth = ctx.createGain();
      lfo.frequency.value = 0.09;
      depth.gain.value = 160;
      lfo.connect(depth).connect(f.frequency);
      lfo.start();
    } else {
      f.type = "highpass";
      f.frequency.value = 45;
      f.Q.value = 0.5;
      makeup.gain.value = 1.2;
    }
    return f;
  };
  return { buffer, chain };
}

function loopableNoise(ctx: AudioContext, seconds: number, colour: "brown" | "pink") {
  const rate = ctx.sampleRate;
  const len = Math.floor(seconds * rate);
  const fade = Math.floor(0.4 * rate);
  const raw = new Float32Array(len + fade);
  let b0 = 0,
    b1 = 0,
    b2 = 0,
    b3 = 0,
    b4 = 0,
    b5 = 0,
    b6 = 0,
    last = 0;
  for (let i = 0; i < raw.length; i++) {
    const w = Math.random() * 2 - 1;
    if (colour === "brown") {
      last = (last + 0.02 * w) / 1.02;
      raw[i] = last * 3.2;
    } else {
      b0 = 0.99886 * b0 + w * 0.0555179;
      b1 = 0.99332 * b1 + w * 0.0750759;
      b2 = 0.969 * b2 + w * 0.153852;
      b3 = 0.8665 * b3 + w * 0.3104856;
      b4 = 0.55 * b4 + w * 0.5329522;
      b5 = -0.7616 * b5 - w * 0.016898;
      raw[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.11;
      b6 = w * 0.115926;
    }
  }
  // Crossfade the tail into the head so the loop point is seamless.
  const buffer = ctx.createBuffer(1, len, rate);
  const out = buffer.getChannelData(0);
  for (let i = 0; i < len; i++) out[i] = raw[i];
  for (let i = 0; i < fade; i++) {
    const a = (i / fade) * (Math.PI / 2);
    out[i] = raw[i] * Math.sin(a) + raw[len + i] * Math.cos(a);
  }
  return buffer;
}

/* The cabin announcement in "Where to next?": the two-tone chime, then a
   recorded voice heard through the cabin speakers (a narrow band, a little
   grit). The recording is optional: without it, only the chime plays. */

const ANNOUNCEMENT = `${BASE}/announcement.mp3`;

let cabinCtx: AudioContext | null = null;
let voice: AudioBuffer | null | undefined;
let playing = false;

function context() {
  const AC: typeof AudioContext =
    window.AudioContext ??
    (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
  return (cabinCtx ??= new AC());
}

async function loadVoice(c: AudioContext) {
  if (voice !== undefined) return voice;
  try {
    const res = await fetch(ANNOUNCEMENT);
    const type = res.headers.get("content-type") ?? "";
    voice =
      res.ok && !type.includes("text/html")
        ? await c.decodeAudioData(await res.arrayBuffer())
        : null;
  } catch {
    voice = null;
  }
  return voice;
}

/* Ding — dong, as on board. */
function chime(c: AudioContext, at: number) {
  const out = c.createGain();
  out.gain.value = 0.22;
  out.connect(c.destination);
  [
    [1318.5, 0],
    [1046.5, 0.62],
  ].forEach(([f, t]) => {
    for (const [mult, level] of [
      [1, 1],
      [2.01, 0.18],
      [3.0, 0.06],
    ]) {
      const o = c.createOscillator();
      const g = c.createGain();
      o.type = "sine";
      o.frequency.value = f * mult;
      g.gain.setValueAtTime(0, at + t);
      g.gain.linearRampToValueAtTime(level, at + t + 0.008);
      g.gain.exponentialRampToValueAtTime(0.0001, at + t + 2.2);
      o.connect(g).connect(out);
      o.start(at + t);
      o.stop(at + t + 2.3);
    }
  });
  return 1.5;
}

/* A voice through cabin speakers. */
function speak(c: AudioContext, buf: AudioBuffer, at: number) {
  const src = c.createBufferSource();
  src.buffer = buf;
  const hp = c.createBiquadFilter();
  hp.type = "highpass";
  hp.frequency.value = 320;
  const lp = c.createBiquadFilter();
  lp.type = "lowpass";
  lp.frequency.value = 3600;
  const mid = c.createBiquadFilter();
  mid.type = "peaking";
  mid.frequency.value = 1700;
  mid.gain.value = 5;
  const grit = c.createWaveShaper();
  const curve = new Float32Array(1024);
  for (let i = 0; i < curve.length; i++) {
    const x = (i / (curve.length - 1)) * 2 - 1;
    curve[i] = Math.tanh(x * 1.8) / Math.tanh(1.8);
  }
  grit.curve = curve;
  const g = c.createGain();
  g.gain.value = 0.9;
  src.connect(hp).connect(lp).connect(mid).connect(grit).connect(g).connect(c.destination);
  src.start(at);
  return buf.duration;
}

/** Fetches and decodes the recording ahead of time, so it starts at once. */
function prepare() {
  void loadVoice(context());
}

/** Plays chime and announcement once at a time. Call from a gesture the
 *  first time (browsers ask for one before sound). */
async function announce() {
  if (playing) return;
  playing = true;
  const c = context();
  if (c.state !== "running") await c.resume().catch(() => {});
  const buf = await loadVoice(c);
  const now = c.currentTime + 0.05;
  let length = chime(c, now) - 0.6;
  if (buf) length += speak(c, buf, now + 0.9);
  window.setTimeout(() => (playing = false), (length + 0.5) * 1000);
}

/* Line icons at a light stroke, in the colour of the text. */
function Icon({
  icon: Glyph,
  size = 16,
  className,
}: {
  icon: LucideIcon;
  size?: number;
  className?: string;
}) {
  return (
    <Glyph
      size={size}
      strokeWidth={1.25}
      className={className ? `icon ${className}` : "icon"}
      aria-hidden="true"
      focusable="false"
    />
  );
}

/* ——————————————— the page after the film ——————————————— */

/* The page after the film reads as a website:
   a bar, an introduction, how it works, the fleet, destinations, the
   experience and a request form. The cinematic moments are kept, but each
   one earns its place:

     sky band     "Beyond first class." — sky and aircraft, then cloud decks
                  in parallax that become the page below
     fleet        a sticky picture that changes with the class in view
     cutaway      outside → inside, revealed from left to right
     head-on      a small window opens to the whole runway
     windows      a row of cabin windows travels past; blinds lift on
                  destinations, flight-time arcs draw
     far away     a descent through cloud onto the airfield, then the
                  long pull-back

   Every move is scrubbed by the scroll on the page's single loop. Type on
   paper is graphite; type on pictures is white. One word per headline may
   be set in Instrument Serif italic as an accent. Under reduced motion
   everything stands still. All brand facts, prices and figures are demo
   content. */

type Track = { enter: number; pin: number; all: number; w: number; h: number };

const IMG = BASE;
const MAIL = "mailto:charter@gateless.example";
const mailFor = (from: string, to: string) =>
  `${MAIL}?subject=${encodeURIComponent(`Charter request: ${from} to ${to}`)}`;

function CharterSections({ still }: { still: boolean }) {
  return (
    <div className={`site${still ? " site--still" : ""}`}>
      <SiteNav still={still} />
      <Intro still={still} />
      <Steps still={still} />
      <Fleet still={still} />
      <SkyBand still={still} />
      <Cutaway still={still} />
      <ViewHeadOn still={still} />
      <Windows still={still} />
      <Routes still={still} />
      <ExperienceHead still={still} />
      <Row
        still={still}
        label="Experience · Terminal"
        icon={Building2}
        lines={[
          <>Your own terminal,</>,
          <>
            no <em className="accent">queues.</em>
          </>,
        ]}
        body="Your car drives up to the private terminal. Passports take minutes, and you walk across the apron to the aircraft."
        photo={{
          name: "ground",
          alt: "Three people walking across the apron towards a private jet at sunset.",
        }}
        layout="scope"
        move={(t, frame, img) => {
          // A letterbox opening from the middle, as at the start of a film.
          const u = easeInOut(clamp((t.enter - 0.04) / 0.62));
          put(frame, "clip-path", `inset(${((1 - u) * 50).toFixed(2)}% 0)`);
          put(img, "transform", `translate3d(${lerp(5, -5, t.all).toFixed(2)}%, 0, 0) scale(1.2)`);
        }}
      />
      <Row
        still={still}
        label="Experience · Cabin"
        icon={Sofa}
        lines={[
          <>The cabin</>,
          <>
            is <em className="accent">yours.</em>
          </>,
        ]}
        body="Catering from a restaurant you choose, Wi-Fi above the clouds, room for skis, dogs and last-minute changes."
        photo={{
          name: "inside",
          alt: "A man in a private jet cabin, reading his phone in low evening sun.",
        }}
        soft={`${IMG}/inside-blur-1400.webp`}
        layout="portrait"
        move={(t, frame, img, soft) => {
          const u = easeInOut(clamp((t.enter - 0.02) / 0.6));
          put(frame, "clip-path", `inset(${((1 - u) * 100).toFixed(2)}% 0 0 0)`);
          // A focus pull: the blurred twin fades off the sharp picture.
          const focus = smooth(clamp((t.enter - 0.45) / 0.5));
          if (soft) put(soft, "opacity", (1 - focus).toFixed(3));
          const s = `scale(${lerp(1.14, 1.0, easeOut(t.all)).toFixed(4)})`;
          put(img, "transform", s);
          if (soft) put(soft, "transform", s);
        }}
      />
      <Dusk still={still} />
      <Colophon still={still} />
    </div>
  );
}

/* ——— bar ——— */

function SiteNav({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  useEffect(() => {
    const nav = ref.current;
    if (!nav) return;
    if (still) {
      put(nav, "transform", "none");
      return;
    }
    const run = document.querySelector<HTMLElement>(".air-run");
    const outro = document.querySelector<HTMLElement>(".air-outro");
    const full = [...document.querySelectorAll<HTMLElement>("[data-full]")];
    let show = false;
    return addScene(nav, {
      read() {
        const vh = window.innerHeight;
        const r = run?.getBoundingClientRect();
        // In once the film has let go of the screen's edges.
        const heroDone = !r || r.bottom - vh < (outro?.offsetHeight ?? 0) * 0.45;
        // Out of the way while a picture fills the screen.
        let covered = false;
        for (const el of full) {
          const b = el.getBoundingClientRect();
          if (b.top >= 72 || b.bottom <= 72) continue;
          const after = Number(el.dataset.full || 0);
          const pin = -b.top / Math.max(1, b.height - vh);
          if (pin >= after) covered = true;
        }
        show = heroDone && !covered;
      },
      write() {
        put(nav, "transform", show ? "none" : "translate3d(0, -105%, 0)");
        return false;
      },
    });
  }, [still]);
  return (
    <header className="s-nav" ref={ref}>
      <a className="s-brand" href="#top" onClick={go("top", still)}>
        Gateless
      </a>
      <nav aria-label="Sections">
        <a href="#fleet" onClick={go("fleet", still)} className="s-nav-more">
          Fleet
        </a>
        <a href="#destinations" onClick={go("destinations", still)} className="s-nav-more">
          Destinations
        </a>
        <a href="#experience" onClick={go("experience", still)} className="s-nav-more">
          Experience
        </a>
        <a href={MAIL} className="s-nav-cta">
          Request a flight <Icon icon={ArrowUpRight} size={14} />
        </a>
      </nav>
    </header>
  );
}

const go = (id: string, still: boolean) => (e: MouseEvent) => {
  e.preventDefault();
  document.getElementById(id)?.scrollIntoView({ behavior: still ? "auto" : "smooth" });
};

/* ——— introduction ——— */

function Intro({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const words = useAll(ref, "[data-hw]");
  const fades = useAll(ref, "[data-f]");
  useTrack(ref, still, (t) => {
    // The statement lights up word by word as it comes up the screen.
    const list = words();
    const q = clamp((t.enter - 0.18) / 0.72) * (list.length + 2);
    list.forEach((w, i) => put(w, "opacity", (0.14 + 0.86 * smooth(clamp(q - i))).toFixed(3)));
    fadeIn(fades(), t.enter, 0.12, 0.5, 0.04);
  });
  const statement: [string, boolean][] = [
    ["Private", false],
    ["aviation,", false],
    ["arranged", false],
    ["around", false],
    ["your", true],
    ["day,", true],
    ["not", false],
    ["the", false],
    ["other", false],
    ["way", false],
    ["round.", false],
  ];
  return (
    <section className="s-intro s-grid" id="charter" ref={ref}>
      <p className="s-label s-intro-label" data-f>
        Private charter
      </p>
      <p className="s-statement">
        {statement.map(([w, accent], i) => (
          <Fragment key={i}>
            <span className={`s-hw${accent ? " accent" : ""}`} data-hw>
              {w}
            </span>
            {i < statement.length - 1 ? " " : null}
          </Fragment>
        ))}
      </p>
      <p className="s-body s-intro-body" data-f>
        Gateless arranges private flights across Europe and beyond, from a short hop to Geneva to a
        non-stop crossing of the Atlantic. One request, a shortlist of aircraft within the hour, one
        fixed price.
      </p>
      <dl className="s-meta" data-f>
        <div>
          <dt>
            <Icon icon={Clock} />
            Response
          </dt>
          <dd>Within the hour</dd>
        </div>
        <div>
          <dt>
            <Icon icon={Globe} />
            Coverage
          </dt>
          <dd>Europe and beyond</dd>
        </div>
        <div>
          <dt>
            <Icon icon={Headset} />
            Availability
          </dt>
          <dd>24 / 7</dd>
        </div>
      </dl>
    </section>
  );
}

/* ——— how it works: the jet taxis from request to runway ———

   Seen from above, on the apron. The ground moves under a jet that stays
   in place; it follows the taxiway line past the three steps painted on
   the ground, turns onto the runway, speeds up and lifts off — straight
   into the sky of the next section.

   Ground units: the stage is 100 high; x is % of its width. */

const STEPS: [string, string][] = [
  ["Request", "Send the route."],
  ["Choose", "Choose your aircraft."],
  ["Fly", "Arrive 15 minutes before departure."],
];

const TAXI = {
  ground: 620, // height of the ground in units
  jetY: 40, // where the jet sits on the stage
  stations: [118, 214, 310], // where each step is painted
  runway: 372, // start of the runway
  lift: [452, 520] as const, // wheels off → climbed away
};

function taxiX(y: number, narrow: boolean) {
  const sm = (a: number, b: number) => smooth(clamp((y - a) / (b - a)));
  const base = narrow ? 50 : 63;
  const bend = narrow ? 9 : 8;
  return base - bend * sm(140, 190) + bend * 1.25 * sm(236, 290);
}

/* Where the jet is on the ground for a scroll progress: steady, then
   faster and faster down the runway. */
function taxiY(p: number) {
  return 28 + 500 * (0.76 * p + 0.24 * p * p * p * p);
}

function Steps({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const ground = useRef<HTMLDivElement>(null);
  const jet = useRef<HTMLDivElement>(null);
  const shadow = useRef<HTMLDivElement>(null);
  const items = useRef<(HTMLLIElement | null)[]>([]);
  const masks = useAll(ref, "[data-m]");
  const fades = useAll(ref, "[data-f]");
  const [narrow, setNarrow] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 640px), (max-aspect-ratio: 4/5)");
    const on = () => setNarrow(mq.matches);
    on();
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);
  const draw = (p: number, w: number, h: number) => {
    const y = taxiY(p);
    const x = taxiX(y, narrow);
    // Heading from the line just ahead and behind, in pixels.
    const dx = ((taxiX(y + 2, narrow) - taxiX(y - 2, narrow)) / 100) * w;
    const dy = (4 / 100) * h;
    const angle = 180 - (Math.atan2(dx, dy) * 180) / Math.PI;
    const up = smooth(clamp((y - TAXI.lift[0]) / (TAXI.lift[1] - TAXI.lift[0])));
    put(
      ground.current!,
      "transform",
      `translate3d(0, ${((-(y - TAXI.jetY) / TAXI.ground) * 100).toFixed(3)}%, 0)`,
    );
    const place = `translate3d(${x.toFixed(3)}vw, 0, 0)`;
    put(
      jet.current!,
      "transform",
      `${place} translate(-50%, -50%) rotate(${angle.toFixed(2)}deg) scale(${(1 + up * 0.32).toFixed(4)})`,
    );
    // The shadow stays on the ground, low sun from the upper right, and
    // falls away as the jet climbs.
    const off = h * (0.05 + up * 0.16);
    put(
      shadow.current!,
      "transform",
      `${place} translate(-50%, -50%) translate(${(-off * 0.9).toFixed(1)}px, ${(off * 0.8).toFixed(1)}px) rotate(${angle.toFixed(2)}deg) scale(${(1 - up * 0.08).toFixed(4)})`,
    );
    put(shadow.current!, "opacity", (0.62 * (1 - up * 0.85)).toFixed(3));
    put(shadow.current!, "filter", `blur(${(2 + up * 12).toFixed(1)}px)`);
    // A step lights up as the jet reaches its mark.
    // The first step is there from the start.
    const active = Math.max(0, TAXI.stations.reduce((n, s) => (y >= s - 6 ? n + 1 : n), 0) - 1);
    items.current.forEach(
      (li, i) => li && li.classList.toggle("is-on", i === active || (narrow ? false : i < active)),
    );
    items.current.forEach((li, i) => li && li.classList.toggle("is-now", i === active));
  };
  useTrack(ref, still, (t) => {
    maskIn(masks(), t.enter, 0.3, 0.62, 0.05);
    fadeIn(fades(), t.enter, 0.45, 0.8, 0.05);
    draw(t.pin, t.w, t.h);
  });
  // Reduced motion: one still frame, mid-way, with every step shown.
  useEffect(() => {
    if (!still || !ref.current) return;
    draw(0.42, ref.current.clientWidth, window.innerHeight);
    items.current.forEach((li) => li?.classList.add("is-on"));
  }, [still, narrow]);
  const at = (y: number) => `${((y / TAXI.ground) * 100).toFixed(4)}%`;
  // The taxiway line, sampled.
  const line: string[] = [];
  for (let y = 0; y <= TAXI.runway + 4; y += 2) line.push(`${taxiX(y, narrow).toFixed(3)},${y}`);
  const lights: [number, number][] = [];
  for (let y = 8; y < TAXI.runway - 6; y += 22) lights.push([y, taxiX(y, narrow)]);
  const rx = taxiX(TAXI.runway, narrow);
  return (
    <section className="v s-taxi" id="how" ref={ref} aria-labelledby="how-title">
      <div className="v-stage s-taxi-stage">
        <div className="s-taxi-ground" ref={ground} aria-hidden="true">
          <div
            className="s-taxi-runway"
            style={{
              top: at(TAXI.runway),
              left: `${rx - (narrow ? 34 : 17)}%`,
              right: `${100 - rx - (narrow ? 34 : 17)}%`,
            }}
          >
            <div className="s-taxi-threshold">
              {Array.from({ length: 8 }, (_, i) => (
                <span key={i} />
              ))}
            </div>
            <span className="s-taxi-designator">27</span>
            <span className="s-taxi-centre" />
          </div>
          <svg
            className="s-taxi-paint"
            viewBox={`0 0 100 ${TAXI.ground}`}
            preserveAspectRatio="none"
          >
            <polyline className="s-taxi-tyres" points={line.join(" ")} />
            <polyline className="s-taxi-line" points={line.join(" ")} />
          </svg>
          {lights.map(([y, x], i) => (
            <Fragment key={i}>
              <span
                className="s-taxi-light"
                style={{ top: at(y), left: `calc(${x}% - ${narrow ? 30 : 13}vmin)` }}
              />
              <span
                className="s-taxi-light"
                style={{ top: at(y), left: `calc(${x}% + ${narrow ? 30 : 13}vmin)` }}
              />
            </Fragment>
          ))}
          {TAXI.stations.map((y, i) => (
            <span
              key={y}
              className="s-taxi-mark"
              style={{ top: at(y), left: `calc(${taxiX(y, narrow)}% + ${narrow ? 6 : 4}vmin)` }}
            >
              {String(i + 1).padStart(2, "0")}
            </span>
          ))}
        </div>
        <div className="s-taxi-shade" aria-hidden="true" />
        <div className="s-taxi-jet s-taxi-jet--shadow" ref={shadow} aria-hidden="true">
          <JetTop shadow />
        </div>
        <div className="s-taxi-jet" ref={jet} aria-hidden="true">
          <JetTop />
        </div>
        <div className="s-taxi-copy">
          <p className="s-label" data-f>
            How it works
          </p>
          <h2 className="s-h3" id="how-title">
            <Mask>From request</Mask>
            <Mask>
              to <em className="accent">runway.</em>
            </Mask>
          </h2>
          <ol className="s-taxi-steps">
            {STEPS.map(([title, text], i) => (
              <li
                key={title}
                ref={(el) => {
                  items.current[i] = el;
                }}
              >
                <h3>
                  <span>{String(i + 1).padStart(2, "0")}</span>
                  {title}
                </h3>
                <p>{text}</p>
              </li>
            ))}
          </ol>
        </div>
      </div>
    </section>
  );
}

/* The jet from directly above (a photograph, cut out, nose up), with its
   navigation lights: red on the left wing tip, green on the right, a red
   beacon on top and white strobes. The shadow is the same picture, black. */
function JetTop({ shadow = false }: { shadow?: boolean }) {
  return (
    <>
      <img className="s-jet" src={`${IMG}/jet-top.webp`} alt="" decoding="async" />
      {shadow ? null : (
        <>
          <span className="s-jet-light s-jet-light--port" />
          <span className="s-jet-light s-jet-light--star" />
          <span className="s-jet-light s-jet-strobe s-jet-strobe--port" />
          <span className="s-jet-light s-jet-strobe s-jet-strobe--star" />
          <span className="s-jet-light s-jet-beacon" />
        </>
      )}
    </>
  );
}

/* ——— sky band: "Beyond first class." ———

   A photograph of the jet above a sea of cloud. The jet itself is cut out
   of the same photograph and laid over the line, so the words slide behind
   the aircraft as you scroll; below, the picture fades into the page. */

function SkyBand({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const photo = useRef<HTMLImageElement>(null);
  const jet = useRef<HTMLImageElement>(null);
  const copy = useRef<HTMLDivElement>(null);
  const masks = useAll(ref, "[data-m]");
  const fades = useAll(ref, "[data-f]");
  useTrack(
    ref,
    still,
    (t) => {
      const a = t.all;
      // Picture and cut-out move as one; the words travel further.
      const pic = `translate3d(0, ${lerp(-3, 5, a).toFixed(2)}%, 0) scale(1.04)`;
      put(photo.current!, "transform", pic);
      put(jet.current!, "transform", pic);
      put(copy.current!, "transform", `translate3d(0, ${lerp(-60, 170, a).toFixed(1)}px, 0)`);
      maskIn(masks(), t.enter, 0.3, 0.7, 0.06);
      fadeIn(fades(), t.enter, 0.5, 0.85, 0.05);
    },
    photo,
  );
  return (
    <section className="s-sky" ref={ref} aria-labelledby="beyond">
      <img
        ref={photo}
        className="s-sky-photo"
        src={`${IMG}/sky-profile.webp`}
        srcSet={`${IMG}/sky-profile-1200.webp 1200w, ${IMG}/sky-profile.webp 2400w`}
        sizes="(max-width: 640px) 200vw, 100vw"
        alt="A private jet in profile, high above a sea of cloud."
        loading="lazy"
        decoding="async"
      />
      <div className="s-sky-copy" ref={copy}>
        <p className="v-label" data-f>
          Gateless · Private charter
        </p>
        <h2 className="s-sky-title" id="beyond">
          <Mask>Beyond</Mask>
          <Mask>
            <em className="accent">first class.</em>
          </Mask>
        </h2>
      </div>
      <img
        ref={jet}
        className="s-sky-photo s-sky-jet"
        src={`${IMG}/sky-profile-jet.webp`}
        srcSet={`${IMG}/sky-profile-jet-1200.webp 1200w, ${IMG}/sky-profile-jet.webp 2400w`}
        sizes="(max-width: 640px) 200vw, 100vw"
        alt=""
        loading="lazy"
        decoding="async"
      />
    </section>
  );
}

/* ——— fleet: a sticky picture that follows the class in view ——— */

const FLEET = [
  {
    cls: "Light jet",
    seats: "4 to 7",
    range: "2,800 km",
    cabin: "1.50 m",
    trip: "Nice to Geneva",
    note: "Short hops across Europe, in and out of small airports close to where you are going.",
    img: `${IMG}/far-1400.webp`,
  },
  {
    cls: "Midsize jet",
    seats: "7 to 9",
    range: "5,000 km",
    cabin: "1.75 m",
    trip: "London to Mykonos",
    note: "A stand-up cabin and the range for most of Europe and North Africa without a stop.",
    img: `${IMG}/above-1400.webp`,
  },
  {
    cls: "Heavy jet",
    seats: "10 to 14",
    range: "7,500 km",
    cabin: "1.85 m",
    trip: "Geneva to Dubai",
    note: "A full cabin with a separate rest area, for families, teams and longer days.",
    img: `${IMG}/cabin-leather-1400.webp`,
  },
  {
    cls: "Ultra long range",
    seats: "12 to 16",
    range: "12,000 km",
    cabin: "1.90 m",
    trip: "London to Los Angeles",
    note: "Intercontinental without a stop: several zones, a proper bed, a crew rest.",
    img: `${IMG}/window-wing-1400.webp`,
  },
];

function Fleet({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const visual = useRef<HTMLDivElement>(null);
  const masks = useAll(ref, ".s-fleet-head [data-m]");
  const fades = useAll(ref, ".s-fleet-head [data-f]");
  useTrack(ref, still, (t) => {
    maskIn(masks(), t.enter, 0.3, 0.62, 0.05);
    fadeIn(fades(), t.enter, 0.4, 0.72, 0.04);
    const u = easeInOut(clamp((t.enter - 0.35) / 0.6));
    put(visual.current!, "clip-path", `inset(${((1 - u) * 100).toFixed(2)}% 0 0 0)`);
  });
  // Which class is in the middle of the screen: its picture and its text.
  useEffect(() => {
    if (still) return;
    const el = ref.current;
    if (!el) return;
    const items = [...el.querySelectorAll<HTMLElement>(".s-fleet-item")];
    const parts = items.map((it) => [...it.querySelectorAll<HTMLElement>("[data-fl]")]);
    const pics = [...el.querySelectorAll<HTMLElement>(".s-fleet-visual .s-fleet-pic")];
    const count = el.querySelector<HTMLElement>(".s-fleet-count");
    const centres: number[] = [];
    const tops: number[] = [];
    let mid = 0;
    let vh = 0;
    let active = -1;
    return addScene(el, {
      read() {
        vh = window.innerHeight;
        mid = vh * 0.5;
        items.forEach((it, i) => {
          const r = it.getBoundingClientRect();
          centres[i] = r.top + r.height / 2;
          tops[i] = r.top;
        });
      },
      write() {
        // Each class's lines arrive one after another as it comes up.
        parts.forEach((ps, i) =>
          fadeIn(ps, clamp((vh * 0.92 - tops[i]) / (vh * 0.42)), 0, 0.7, 0.12),
        );
        let best = 0;
        let bestD = Infinity;
        centres.forEach((c, i) => {
          const d = Math.abs(c - mid);
          if (d < bestD) {
            bestD = d;
            best = i;
          }
        });
        if (best !== active) {
          active = best;
          pics.forEach((p, i) => {
            put(p, "opacity", i === best ? "1" : "0");
            put(p, "transform", i === best ? "scale(1)" : "scale(1.06)");
          });
          items.forEach((it, i) => put(it, "opacity", i === best ? "1" : "0.28"));
          if (count)
            count.textContent = `${String(best + 1).padStart(2, "0")} / ${String(items.length).padStart(2, "0")}`;
        }
        return false;
      },
    });
  }, [still]);
  return (
    <section className="s-fleet" id="fleet" ref={ref}>
      <div className="s-fleet-head s-grid">
        <p className="s-label s-fleet-label" data-f>
          Fleet
        </p>
        <h2 className="s-h2 s-fleet-title">
          <Mask>The right aircraft</Mask>
          <Mask>
            for the <em className="accent">trip.</em>
          </Mask>
        </h2>
      </div>
      <div className="s-fleet-body s-grid">
        <div className="s-fleet-sticky">
          <div className="s-fleet-visual" ref={visual}>
            {FLEET.map((f, i) => (
              <img
                key={f.cls}
                className="s-fleet-pic"
                src={f.img}
                alt=""
                loading="lazy"
                decoding="async"
                style={{ opacity: i === 0 ? 1 : 0 }}
              />
            ))}
            <span className="s-fleet-count v-label">01 / 04</span>
          </div>
        </div>
        <ol className="s-fleet-list">
          {FLEET.map((f) => (
            <li className="s-fleet-item" key={f.cls}>
              <img
                className="s-fleet-item-pic"
                src={f.img}
                alt=""
                loading="lazy"
                decoding="async"
              />
              <h3 className="s-h3" data-fl>
                {f.cls}
              </h3>
              <p className="s-body" data-fl>
                {f.note}
              </p>
              <dl className="s-specs" data-fl>
                <div>
                  <dt>
                    <Icon icon={Armchair} />
                    Seats
                  </dt>
                  <dd>{f.seats}</dd>
                </div>
                <div>
                  <dt>
                    <Icon icon={Route} />
                    Range
                  </dt>
                  <dd>{f.range}</dd>
                </div>
                <div>
                  <dt>
                    <Icon icon={Ruler} />
                    Cabin height
                  </dt>
                  <dd>{f.cabin}</dd>
                </div>
                <div>
                  <dt>
                    <Icon icon={MapPin} />
                    Typical trip
                  </dt>
                  <dd>{f.trip}</dd>
                </div>
              </dl>
            </li>
          ))}
        </ol>
      </div>
    </section>
  );
}

/* ——— cutaway: outside, an aircraft; inside, a room ———

   The jet from the side at a size the picture can carry. Pointing at it
   (or tapping) fades the cabin in: the two pictures are the same pixel for
   pixel apart from the cabin, so only the cabin seems to open. */

function Cutaway({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const [inside, setInside] = useState(false);
  const masks = useAll(ref, "[data-m]");
  const fades = useAll(ref, "[data-f]");
  useTrack(ref, still, (t) => {
    maskIn(masks(), t.enter, 0.3, 0.62, 0.06);
    fadeIn(fades(), t.enter, 0.42, 0.8, 0.06);
  });
  return (
    <section className="s-cut" ref={ref} aria-labelledby="cutaway">
      <h2 className="s-h2 s-cut-title" id="cutaway">
        <Mask>
          Outside, an <em className="accent">aircraft.</em>
        </Mask>
        <Mask>
          Inside, a <em className="accent">room.</em>
        </Mask>
      </h2>
      <button
        type="button"
        className={`s-cut-frame${inside ? " is-inside" : ""}`}
        // A mouse looks inside while it points; a finger toggles.
        onPointerEnter={(e) => e.pointerType === "mouse" && setInside(true)}
        onPointerLeave={(e) => e.pointerType === "mouse" && setInside(false)}
        onPointerUp={(e) => e.pointerType !== "mouse" && setInside((v) => !v)}
        onClick={(e) => e.detail === 0 && setInside((v) => !v)}
        aria-pressed={inside}
        aria-label="Show the cabin"
        data-f
      >
        <img
          className="s-cut-img"
          src={`${IMG}/cutaway-exterior.webp`}
          alt="The private jet from the side."
          loading="lazy"
          decoding="async"
        />
        <img
          className="s-cut-img s-cut-img--in"
          src={`${IMG}/cutaway-interior.webp`}
          alt="The same jet with the side of its cabin taken away, showing the seats inside."
          loading="lazy"
          decoding="async"
        />
      </button>
      <p className="s-label s-cut-hint" data-f>
        <span className="s-cut-hint-hover">Point at the aircraft to look inside</span>
        <span className="s-cut-hint-touch">Tap the aircraft to look inside</span>
      </p>
    </section>
  );
}

/* ——— ready: an aircraft-window cut-out opens to the whole picture, then
   the jet comes towards you ——— */

const READY_STATS: [LucideIcon, string, string][] = [
  [Timer, "Response", "60 min"],
  [Plane, "Fleet", "4 classes"],
  [TowerControl, "Airports", "5,000+"],
];

function ViewHeadOn({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const win = useRef<HTMLDivElement>(null);
  const img = useRef<HTMLImageElement>(null);
  const left = useRef<HTMLDivElement>(null);
  const right = useRef<HTMLDivElement>(null);
  const caption = useAll(ref, ".v-headon-caption [data-m]");
  useTrack(
    ref,
    still,
    (t) => {
      const narrow = t.w < t.h * 0.8 || t.w <= 640;
      // At rest: a cabin window — tall, with fully rounded ends.
      const w0 = narrow ? t.w * 0.5 : Math.min(t.w * 0.17, t.h * 0.34);
      const h0 = w0 * 1.38;
      const shutter = smooth(clamp((t.enter - 0.45) / 0.5));
      const open = easeInOut(clamp((t.pin - 0.1) / 0.58));
      const ix = ((t.w - w0) / 2) * (1 - open);
      const iy = ((t.h - h0 * shutter) / 2) * (1 - open);
      const r = (w0 / 2) * (1 - open);
      put(
        win.current!,
        "clip-path",
        `inset(${iy.toFixed(1)}px ${ix.toFixed(1)}px round ${r.toFixed(1)}px)`,
      );
      // The picture settles while the window opens; then the jet comes
      // towards you, faster as it gets closer.
      const settle = lerp(1.14, 1.0, easeOut(clamp(t.pin / 0.6)));
      const near = 1 + 0.2 * Math.pow(clamp((t.pin - 0.48) / 0.52), 1.4);
      put(img.current!, "transform", `scale(${(settle * near).toFixed(4)})`);
      // The details either side come into focus, then are pushed out by
      // the opening window.
      const push = ((t.w - w0) / 2) * open;
      const pushY = ((t.h - h0) / 2) * open;
      const wordsIn = easeOut(clamp((t.enter - 0.55) / 0.4));
      const wordsOut = smooth(clamp((t.pin - 0.12) / 0.32));
      const o = (wordsIn * (1 - wordsOut)).toFixed(3);
      const blur = (1 - wordsIn) * 8 + wordsOut * 6;
      for (const el of [left.current!, right.current!]) {
        put(el, "opacity", o);
        put(el, "filter", blur < 0.05 ? "none" : `blur(${blur.toFixed(2)}px)`);
      }
      const rise = (1 - wordsIn) * 14;
      put(
        left.current!,
        "transform",
        narrow
          ? `translate3d(0, ${(rise - pushY).toFixed(1)}px, 0)`
          : `translate3d(${(-push).toFixed(1)}px, ${rise.toFixed(1)}px, 0)`,
      );
      put(
        right.current!,
        "transform",
        narrow
          ? `translate3d(0, ${(rise + pushY).toFixed(1)}px, 0)`
          : `translate3d(${push.toFixed(1)}px, ${rise.toFixed(1)}px, 0)`,
      );
      put(ref.current!, "--w0", `${w0.toFixed(1)}px`);
      put(ref.current!, "--h0", `${h0.toFixed(1)}px`);
      maskIn(caption(), t.pin, 0.7, 0.86, 0.03);
    },
    img,
  );
  return (
    <section className="v v-headon" ref={ref} aria-labelledby="ready" data-full="0.3">
      <div className="v-stage">
        <div className="v-window" ref={win}>
          <Photo
            imgRef={img}
            name="headon3"
            big={2688}
            alt="A private jet facing the camera on a runway, mountains in haze behind it, in black and white."
          />
        </div>
        <div className="v-flank v-flank--left" ref={left} aria-hidden="true">
          <dl className="v-stats">
            {READY_STATS.map(([icon, k, v]) => (
              <div key={k}>
                <dt>
                  <Icon icon={icon} size={14} />
                  {k}
                </dt>
                <dd>{v}</dd>
              </div>
            ))}
          </dl>
          <p className="s-h3 v-flank-title">
            Ready when
            <br />
            you <em className="accent">are.</em>
          </p>
        </div>
        <div className="v-flank v-flank--right" ref={right} aria-hidden="true">
          <p className="v-flank-text">
            From the first call to wheels-up can be as little as three hours. Tell us when; we take
            care of everything else.
          </p>
          <p className="s-label v-flank-meta">Available 24 / 7</p>
        </div>
        <div className="v-copy v-copy--low v-headon-caption">
          <p className="v-label">
            <Mask>Available 24 / 7</Mask>
          </p>
          <h2 className="v-head" id="ready">
            <Mask>Ready</Mask>
            <Mask>
              when you <em className="accent">are.</em>
            </Mask>
          </h2>
        </div>
      </div>
    </section>
  );
}

/* ——— windows: destinations seen through a row of cabin windows ——— */

const WINDOWS = [
  {
    city: "Geneva",
    region: "Switzerland",
    time: "1 h 25",
    hours: 1.42,
    img: `${IMG}/destinations/geneva.webp`,
    pos: "50% 50%",
  },
  {
    city: "Ibiza",
    region: "Spain",
    time: "2 h 20",
    hours: 2.33,
    img: `${IMG}/destinations/ibiza.webp`,
    pos: "50% 50%",
  },
  {
    city: "Mykonos",
    region: "Greece",
    time: "3 h 35",
    hours: 3.58,
    img: `${IMG}/destinations/mykonos.webp`,
    pos: "50% 50%",
  },
  {
    city: "Dubai",
    region: "United Arab Emirates",
    time: "6 h 40",
    hours: 6.67,
    img: `${IMG}/destinations/dubai.webp`,
    pos: "50% 45%",
  },
  {
    city: "New York",
    region: "United States",
    time: "7 h 50",
    hours: 7.83,
    img: `${IMG}/destinations/new-york.webp`,
    pos: "50% 50%",
  },
];
const MAX_HOURS = 9;
/* The view is 160 % of its window wide; it may slide ±30 % of the window,
   which is 18.75 % of its own width. */
const WIN_PARALLAX = 18.75;

function Windows({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const track = useRef<HTMLDivElement>(null);
  const masks = useAll(ref, ".s-win-head [data-m]");
  const fades = useAll(ref, ".s-win-head [data-f]");
  const parts = useRef<
    | {
        win: HTMLElement;
        shade: HTMLElement;
        view: HTMLElement;
        arc: SVGPathElement;
        data: HTMLElement[];
      }[]
    | null
  >(null);
  const geo = useRef({ trackW: 0, centres: [] as number[], viewW: 0 });
  const announced = useRef(false);
  const prepared = useRef(false);
  useTrack(
    ref,
    still,
    (t) => {
      const el = ref.current!;
      parts.current ??= [...el.querySelectorAll<HTMLElement>(".s-win")].map((win) => ({
        win,
        shade: win.querySelector<HTMLElement>(".s-win-shade")!,
        view: win.querySelector<HTMLElement>(".s-win-view img")!,
        arc: win.querySelector<SVGPathElement>(".s-win-arc-fill")!,
        data: [...win.querySelectorAll<HTMLElement>("[data-wd]")],
      }));
      maskIn(masks(), t.enter, 0.35, 0.7, 0.05);
      fadeIn(fades(), t.enter, 0.45, 0.8, 0.04);
      // With sound on (asked for at the top), the cabin speaks once — as the
      // section comes up, with the recording loaded on the way.
      if (document.documentElement.dataset.sound === "on" && !announced.current) {
        if (!prepared.current && t.enter > 0.05) {
          prepared.current = true;
          prepare();
        }
        if (t.enter > 0.5) {
          announced.current = true;
          void announce();
        }
      }
      // The cabin wall travels past: the row moves left across the pin.
      // It stops with the last window a good way in from the right edge.
      const travel = Math.max(0, geo.current.trackW - t.w + Math.max(t.w * 0.1, 64));
      const x = -travel * easeInOut(clamp((t.pin - 0.02) / 0.82));
      put(track.current!, "transform", `translate3d(${x.toFixed(1)}px, 0, 0)`);
      parts.current.forEach((p, i) => {
        const cx = geo.current.centres[i] + x; // centre on screen, px
        // Opens as it comes in, fully open well before the row stops.
        const o = easeOut(clamp((t.w * 0.97 - cx) / (t.w * 0.17)));
        put(p.shade, "transform", `translate3d(0, ${(-o * 101).toFixed(2)}%, 0)`);
        put(p.arc, "stroke-dashoffset", (1 - o * (WINDOWS[i].hours / MAX_HOURS)).toFixed(4));
        p.data.forEach((d, k) => {
          const e = easeOut(clamp(o * 1.4 - 0.25 - k * 0.12));
          put(d, "opacity", e.toFixed(3));
          put(
            d,
            "transform",
            e > 0.999 ? "none" : `translate3d(0, ${((1 - e) * 10).toFixed(1)}px, 0)`,
          );
        });
        // Far away, the view hardly moves while the wall passes: it slides
        // against its window, with the slow sway of a cabin in flight.
        const rel = clamp((cx - t.w / 2) / (t.w * 0.55 + geo.current.viewW), -1, 1);
        const sway = Math.sin(t.pin * Math.PI * 3 + i * 1.7) * 1.4;
        put(
          p.view,
          "transform",
          `translate3d(${(-rel * WIN_PARALLAX).toFixed(2)}%, ${sway.toFixed(2)}%, 0)`,
        );
      });
    },
    track,
  );
  // Geometry that only changes with the layout.
  useEffect(() => {
    const el = ref.current;
    const tr = track.current;
    if (!el || !tr) return;
    const measure = () => {
      const wins = [...tr.querySelectorAll<HTMLElement>(".s-win")];
      geo.current = {
        trackW: tr.scrollWidth,
        centres: wins.map((w) => w.offsetLeft + w.offsetWidth / 2 + tr.offsetLeft),
        viewW: wins[0]?.querySelector<HTMLElement>(".s-win-view")?.offsetWidth ?? 0,
      };
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(tr);
    return () => ro.disconnect();
  }, []);
  return (
    <section className="v s-win-section" id="destinations" ref={ref} aria-labelledby="where">
      <div className="v-stage s-win-stage">
        <div className="s-win-head">
          <p className="s-label" data-f>
            Destinations
          </p>
          <h2 className="s-h2" id="where">
            <Mask>
              Where to <em className="accent">next?</em>
            </Mask>
          </h2>
          <button type="button" className="s-win-announce" onClick={() => void announce()} data-f>
            <Icon icon={Volume2} size={15} />
            Cabin announcement
          </button>
        </div>
        <div className="s-win-track" ref={track}>
          {WINDOWS.map((w) => (
            <figure className="s-win" key={w.city}>
              <div className="s-win-frame" aria-hidden="true" />
              <div className="s-win-inner" aria-hidden="true" />
              <div className="s-win-view">
                <img
                  src={w.img}
                  alt=""
                  loading="lazy"
                  decoding="async"
                  style={{ objectPosition: w.pos }}
                />
                <div className="s-win-data">
                  <svg className="s-win-arc" viewBox="0 0 100 56" aria-hidden="true">
                    <path className="s-win-arc-track" d="M8 52 A42 42 0 0 1 92 52" pathLength={1} />
                    <path className="s-win-arc-fill" d="M8 52 A42 42 0 0 1 92 52" pathLength={1} />
                  </svg>
                  <p className="s-win-time" data-wd>
                    <strong>{w.time}</strong>
                    <span>
                      <Icon icon={PlaneTakeoff} size={13} />
                      from London
                    </span>
                  </p>
                  <figcaption className="s-win-city" data-wd>
                    <strong>{w.city}</strong>
                    <span>{w.region}</span>
                  </figcaption>
                </div>
              </div>
              <div className="s-win-blind" aria-hidden="true">
                <div className="s-win-shade" />
              </div>
            </figure>
          ))}
        </div>
      </div>
    </section>
  );
}

/* ——— routes: a large typographic list; a picture shows on hover ——— */

const ROUTES: { from: string; to: string; time: string; jet: string; img: string }[] = [
  {
    from: "Nice",
    to: "Geneva",
    time: "00:55",
    jet: "Light jet",
    img: `${IMG}/destinations/geneva.webp`,
  },
  {
    from: "London",
    to: "Ibiza",
    time: "02:20",
    jet: "Midsize",
    img: `${IMG}/destinations/ibiza.webp`,
  },
  { from: "Munich", to: "Olbia", time: "01:30", jet: "Light jet", img: `${IMG}/coastal-1400.webp` },
  {
    from: "Paris",
    to: "Mykonos",
    time: "03:05",
    jet: "Midsize",
    img: `${IMG}/destinations/mykonos.webp`,
  },
  {
    from: "Geneva",
    to: "Dubai",
    time: "06:10",
    jet: "Heavy jet",
    img: `${IMG}/destinations/dubai.webp`,
  },
  {
    from: "London",
    to: "New York",
    time: "07:50",
    jet: "Ultra long range",
    img: `${IMG}/destinations/new-york.webp`,
  },
];

function Routes({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const masks = useAll(ref, "[data-m]");
  const fades = useAll(ref, "[data-f]");
  useTrack(ref, still, (t) => {
    maskIn(masks(), t.enter, 0.3, 0.75, 0.04);
    fadeIn(fades(), t.enter, 0.35, 0.75, 0.04);
  });
  return (
    <section className="s-routes" ref={ref} aria-labelledby="routes">
      <div className="s-routes-head">
        <p className="s-label" data-f id="routes">
          Popular this season
        </p>
        <p className="s-routes-note" data-f>
          Fixed prices once you confirm.
        </p>
      </div>
      <ol className="s-route-list">
        {ROUTES.map((r) => (
          <li className="s-route" key={r.from + r.to}>
            <a className="s-route-btn" href={mailFor(r.from, r.to)}>
              <span className="s-route-name">
                <Mask>
                  {r.from}
                  <span className="s-route-arrow" aria-hidden="true">
                    →
                  </span>
                  <span className="air-sr"> to </span>
                  {r.to}
                </Mask>
              </span>
              <span className="s-route-meta" data-f>
                {r.time} · {r.jet}
              </span>
            </a>
            <span className="s-route-pic" aria-hidden="true">
              <img src={r.img} alt="" loading="lazy" decoding="async" />
            </span>
          </li>
        ))}
      </ol>
    </section>
  );
}

/* ——— experience ——— */

function ExperienceHead({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const masks = useAll(ref, "[data-m]");
  const fades = useAll(ref, "[data-f]");
  useTrack(ref, still, (t) => {
    maskIn(masks(), t.enter, 0.3, 0.62, 0.05);
    fadeIn(fades(), t.enter, 0.4, 0.7, 0.04);
  });
  return (
    <section className="s-exp s-grid" id="experience" ref={ref}>
      <p className="s-label s-exp-label" data-f>
        Experience
      </p>
      <h2 className="s-h2 s-exp-title">
        <Mask>Door to door,</Mask>
        <Mask>
          without the <em className="accent">airport.</em>
        </Mask>
      </h2>
    </section>
  );
}

/* ——— an editorial row: picture and text ——— */

function Row({
  still,
  label,
  icon,
  lines,
  body,
  photo,
  soft,
  layout,
  move,
}: {
  still: boolean;
  label: string;
  icon: LucideIcon;
  lines: ReactNode[];
  body: string;
  photo: { name: string; alt: string };
  soft?: string;
  layout: "left" | "right" | "scope" | "portrait";
  move: (t: Track, frame: HTMLElement, img: HTMLElement, soft?: HTMLElement) => void;
}) {
  const ref = useRef<HTMLElement>(null);
  const frame = useRef<HTMLDivElement>(null);
  const img = useRef<HTMLImageElement>(null);
  const softRef = useRef<HTMLImageElement>(null);
  const masks = useAll(ref, "[data-m]");
  const fades = useAll(ref, "[data-f]");
  useTrack(
    ref,
    still,
    (t) => {
      move(t, frame.current!, img.current!, softRef.current ?? undefined);
      maskIn(masks(), t.enter, 0.34, 0.66, 0.06);
      fadeIn(fades(), t.enter, 0.44, 0.78, 0.05);
    },
    img,
  );
  return (
    <section className={`s-row s-row--${layout} s-grid`} ref={ref}>
      <div className="s-frame" ref={frame}>
        <Photo imgRef={img} name={photo.name} alt={photo.alt} />
        {soft ? (
          <img
            ref={softRef}
            className="v-img v-img--soft"
            src={soft}
            alt=""
            aria-hidden="true"
            loading="lazy"
            decoding="async"
          />
        ) : null}
      </div>
      <div className="s-text">
        <p className="s-label" data-f>
          <Icon icon={icon} size={14} />
          {label}
        </p>
        <h3 className="s-h3">
          {lines.map((l, i) => (
            <Mask key={i}>{l}</Mask>
          ))}
        </h3>
        <p className="s-body" data-f>
          {body}
        </p>
      </div>
    </section>
  );
}

/* ——— the last shot: a runway in fog, the aircraft far away ———

   The picture comes into focus as it arrives; the line is centred and
   resolves after it. Then a hard cut to the brightest part of the page. */

function Dusk({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const img = useRef<HTMLImageElement>(null);
  const masks = useAll(ref, "[data-m]");
  useTrack(
    ref,
    still,
    (t) => {
      const focus = easeOut(clamp((t.enter - 0.2) / 0.75));
      put(
        img.current!,
        "filter",
        focus > 0.995 ? "none" : `blur(${((1 - focus) * 18).toFixed(2)}px)`,
      );
      // The picture drifts from right to left as you scroll through.
      put(
        img.current!,
        "transform",
        `translate3d(${lerp(6, -6, t.all).toFixed(3)}%, 0, 0) scale(1.15)`,
      );
      maskIn(masks(), t.pin, 0.12, 0.55, 0.12);
    },
    img,
  );
  return (
    <section className="v s-dusk" ref={ref} aria-labelledby="anywhere">
      <div className="v-stage s-dusk-stage">
        <img
          ref={img}
          className="s-dusk-sky"
          src={`${IMG}/dusk-runway.webp`}
          srcSet={`${IMG}/dusk-runway-1200.webp 1200w, ${IMG}/dusk-runway.webp 2400w`}
          sizes="(max-width: 640px) 220vw, 100vw"
          alt="A runway in morning fog under a vast sky; far away, a jet lifts off."
          loading="lazy"
          decoding="async"
        />
        <h2 className="s-h2 s-dusk-line" id="anywhere">
          <Mask>Anywhere means</Mask>
          <Mask>
            <em className="accent">anywhere.</em>
          </Mask>
        </h2>
      </div>
    </section>
  );
}

/* ——— colophon ——— */

function Colophon({ still }: { still: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const mark = useRef<HTMLParagraphElement>(null);
  const fades = useAll(ref, "[data-f]");
  useTrack(ref, still, (t) => {
    fadeIn(fades(), t.enter, 0.2, 0.55, 0.04);
  });
  // The name, letter by letter, once it is properly in view.
  useEffect(() => {
    const el = mark.current;
    if (!el) return;
    if (still) {
      el.classList.add("is-up");
      return;
    }
    const io = new IntersectionObserver(
      ([e]) => {
        if (e.isIntersecting) {
          el.classList.add("is-up");
          io.disconnect();
        }
      },
      { threshold: 0.45 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [still]);
  const toTop = () => window.scrollTo({ top: 0, behavior: still ? "auto" : "smooth" });
  return (
    <footer className="s-colophon" id="colophon" ref={ref}>
      <div className="s-grid s-colophon-grid">
        <p className="s-colophon-line" data-f>
          Private charter,
          <br />
          arranged around <em className="accent">you.</em>
        </p>
        <ul className="s-colophon-index" data-f>
          <li>
            <a href="#fleet" onClick={go("fleet", still)}>
              Fleet
            </a>
          </li>
          <li>
            <a href="#destinations" onClick={go("destinations", still)}>
              Destinations
            </a>
          </li>
          <li>
            <a href="#experience" onClick={go("experience", still)}>
              Experience
            </a>
          </li>
          <li>
            <a href={MAIL}>Request a flight</a>
          </li>
        </ul>
        <div className="s-colophon-side" data-f>
          <p className="s-label">
            <Icon icon={Mail} size={14} />
            charter@gateless.example
          </p>
          <p className="s-label">
            <Icon icon={Headset} size={14} />
            Available 24 / 7
          </p>
          <button type="button" className="s-label s-top" onClick={toTop}>
            <Icon icon={ArrowUp} size={14} />
            Back to the top
          </button>
          <p className="s-label">© 2026 Gateless</p>
        </div>
      </div>
      <p className="s-mark" aria-label="Gateless" ref={mark}>
        {[..."Gateless"].map((c, i) => (
          <span className="m-line" key={i} aria-hidden="true">
            <span className="s-mark-l" style={{ "--i": i } as CSSProperties}>
              {c}
            </span>
          </span>
        ))}
      </p>
    </footer>
  );
}

/* ——— helpers ——— */

/* A line that rises out of its own mask. */
function Mask({ children }: { children: ReactNode }) {
  return (
    <span className="m-line">
      <span className="m-in" data-m>
        {children}
      </span>
    </span>
  );
}

const Photo = ({
  imgRef,
  name,
  alt,
  big = 2400,
  sizes = "100vw",
}: {
  imgRef: RefObject<HTMLImageElement | null>;
  name: string;
  alt: string;
  big?: number;
  sizes?: string;
}) => (
  <img
    ref={imgRef}
    className={`v-img v-img--${name}`}
    src={`${IMG}/${name}-${big}.webp`}
    srcSet={`${IMG}/${name}-1400.webp 1400w, ${IMG}/${name}-${big}.webp ${big}w`}
    sizes={sizes}
    alt={alt}
    loading="lazy"
    decoding="async"
  />
);

/* Registers a section with the page's animation loop and hands `draw` the
   smoothed scroll positions. Does nothing under reduced motion. */
function useTrack(
  ref: RefObject<HTMLElement | null>,
  still: boolean,
  draw: (t: Track) => void,
  layer?: RefObject<HTMLElement | null>,
) {
  const drawRef = useRef(draw);
  drawRef.current = draw;
  useEffect(() => {
    if (still) return;
    const el = ref.current;
    if (!el) return;
    const stage = el.querySelector<HTMLElement>(":scope > .v-stage");
    let rect = el.getBoundingClientRect();
    let vh = window.innerHeight;
    let sh = 0;
    let w = 0;
    const cur = { enter: -1, pin: -1, all: -1 };
    return addScene(el, {
      read() {
        rect = el.getBoundingClientRect();
        vh = window.innerHeight;
        sh = stage ? stage.offsetHeight : 0;
        w = el.clientWidth;
      },
      write(_now, dt) {
        const target = {
          enter: clamp((vh - rect.top) / vh),
          pin: stage ? clamp(-rect.top / Math.max(1, rect.height - sh)) : 0,
          all: clamp((vh - rect.top) / Math.max(1, vh + rect.height - sh)),
        };
        let moving = false;
        for (const k of ["enter", "pin", "all"] as const) {
          cur[k] = cur[k] < 0 ? target[k] : follow(cur[k], target[k], dt, 0.09);
          if (cur[k] !== target[k]) moving = true;
        }
        drawRef.current({ ...cur, w, h: sh || vh });
        return moving;
      },
      // Large moving pictures get their own layer only while near the screen.
      onActive: (on) => {
        if (layer?.current) put(layer.current, "will-change", on ? "transform" : "auto");
      },
    });
  }, [ref, still, layer]);
}

/* Elements matching `selector` inside the section, collected once. */
function useAll(ref: RefObject<HTMLElement | null>, selector: string) {
  const cache = useRef<HTMLElement[] | null>(null);
  return () =>
    (cache.current ??= [...(ref.current?.querySelectorAll<HTMLElement>(selector) ?? [])]);
}

/* Out of a line mask and into focus: up from below the line, soft to sharp. */
function maskIn(els: HTMLElement[], p: number, a: number, b: number, stagger: number) {
  els.forEach((el, i) => {
    const e = easeOutQuart(clamp((p - a - i * stagger) / (b - a)));
    put(el, "transform", e > 0.999 ? "none" : `translate3d(0, ${((1 - e) * 112).toFixed(2)}%, 0)`);
    put(el, "opacity", Math.pow(e, 0.6).toFixed(3));
    const blur = (1 - e) * 6;
    put(el, "filter", blur < 0.05 ? "none" : `blur(${blur.toFixed(2)}px)`);
  });
}

/* Smaller text: fades up ≤ 18 px and comes into focus. */
function fadeIn(els: HTMLElement[], p: number, a: number, b: number, stagger: number) {
  els.forEach((el, i) => {
    const e = easeOutQuart(clamp((p - a - i * stagger) / (b - a)));
    put(el, "opacity", e.toFixed(3));
    put(el, "transform", e > 0.999 ? "none" : `translate3d(0, ${((1 - e) * 18).toFixed(2)}px, 0)`);
    const blur = (1 - e) * 4;
    put(el, "filter", blur < 0.05 ? "none" : `blur(${blur.toFixed(2)}px)`);
  });
}

const lerp = (a: number, b: number, u: number) => a + (b - a) * u;
const easeInOut = (u: number) => (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2);

/* ——————————————— the film ——————————————— */

/* One continuous camera move, driven by the scroll.

   inside → through the window → cloud → the aircraft outside → scale.

   The video never plays. The page's scroll position is smoothed (time
   based, so 60 Hz and 120 Hz feel the same), mapped through the timeline in
   timeline above, rounded to a real frame of the 24 fps source and only then
   asked of the decoder: once per new frame, never while a seek is still in
   flight, always catching up to the latest position when it lands. The
   film and the page below share one requestAnimationFrame loop (the
   engine at the top), which reads geometry first and writes styles only when they
   change. No React state changes while scrolling.

   Type is set like film titles: words come out of focus and draw together,
   small labels track in letter by letter, and every block leaves by going
   soft rather than by moving away. When the film is over, the camera pulls
   back once more: the frame lets go of the screen's edges and becomes a
   picture on the page that follows. */

const VIDEO_SRC = `${BASE}/film.mp4`;
const FIRST_STILL = `${BASE}/film-first.webp`;
/* Portrait screens get their own cut: only the band of the frame a phone
   can ever see (x 0.2917–0.8917 of the width, 1152 × 1080), a keyframe
   every 4 frames and no B-frames, so a seek costs at most three decoded
   frames instead of up to 29. Nothing outside that band is ever on screen
   below a 3:5 aspect, whatever the crop's pan. */
const MOBILE = {
  src: `${BASE}/film-phone.mp4`,
  poster: `${BASE}/film-first-phone.webp`,
  x0: 560 / 1920,
  w: 1152 / 1920,
  query: "(max-aspect-ratio: 3/5)",
};
const FINAL_STILL = `${BASE}/film-final.webp`;
const TAU = 0.12; // s — scroll smoothing time constant
/* The Sound control sits over cloud and bright sky from the window onward;
   white is unreadable there (measured ≤ 2:1), so it turns graphite. */
const BRIGHT_FROM = 5.3;

export function GatelessCharterPage() {
  const reduced = useReducedMotion();
  return (
    <main className="air">
      <style>{CSS}</style>
      {reduced ? <CharterStill /> : <CharterFilm />}
      <CharterSections still={reduced} />
    </main>
  );
}

/* ——————————————————————— the film ——————————————————————— */

type Word = { el: HTMLElement; k: number; n: number; char: boolean; t0: number; dur: number };
type Line = { el: HTMLElement; j: number; delay: number; rise: number; words: Word[] };

function CharterFilm() {
  const runRef = useRef<HTMLElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const videoRef = useRef<HTMLVideoElement>(null);
  const grainRef = useRef<HTMLDivElement>(null);
  const cueRef = useRef<HTMLParagraphElement>(null);
  const outroRef = useRef<HTMLDivElement>(null);
  const soundBtnRef = useRef<HTMLButtonElement>(null);
  const soundRef = useRef<AirSound | null>(null);
  const timeRef = useRef(0);
  const [soundOn, setSoundOn] = useState(false);
  const cropped = useMedia(MOBILE.query);
  const geometry = useFilmVars(stageRef, wake);

  useEffect(() => {
    const run = runRef.current;
    const stage = stageRef.current;
    const v = videoRef.current;
    const grain = grainRef.current;
    const cue = cueRef.current;
    const outroEl = outroRef.current;
    const soundBtn = soundBtnRef.current;
    // The crop is only known in the browser; until then nothing is loaded.
    if (cropped === null) return;
    if (!run || !stage || !v || !grain || !cue || !outroEl || !soundBtn) return;

    // Grain, once.
    const dpr = Math.min(3, window.devicePixelRatio || 1);
    grain.style.backgroundImage = `url(${grainTile(128)})`;
    grain.style.backgroundSize = `${128 / dpr}px ${128 / dpr}px`;

    /* React sets `muted` only as a property; iOS also checks the attribute
       before it allows inline playback. */
    v.muted = true;
    v.defaultMuted = true;
    v.setAttribute("muted", "");
    v.setAttribute("playsinline", "");
    v.setAttribute("webkit-playsinline", "");

    /* Blocks → lines → words (or letters). The cold open runs on the clock,
       so its words get start times here: letters of the label first, then
       the headline word by word. */
    const beats = [...stage.querySelectorAll<HTMLElement>("[data-beat]")].map((el) => {
      let rise = 0;
      let clock = 200;
      const lines: Line[] = [...el.querySelectorAll<HTMLElement>("[data-l]")].map((line, j) => {
        const spans = [...line.querySelectorAll<HTMLElement>("[data-w]")];
        const words = spans.map((w, k) => {
          const char = w.classList.contains("air-c");
          const word = { el: w, k, n: spans.length, char, t0: clock, dur: char ? 1100 : 1900 };
          clock += char ? 45 : 170;
          return word;
        });
        clock += 260;
        return {
          el: line,
          j,
          delay: line.dataset.delay ? Number(line.dataset.delay) : j,
          rise: line.hasAttribute("data-rise") ? rise++ : -1,
          words,
        };
      });
      return { el, id: el.dataset.beat as BeatId, lines };
    });
    const distance = beats.find((b) => b.id === "distance")!;
    const sil: number[] = [];

    let disposed = false;
    let cur = 0;
    let outro = 0;
    let first = true;
    let requested = -1;
    const introStart = performance.now();
    let rect = run.getBoundingClientRect();
    let outroPx = 1;
    let distBox = { x: 0, y: 0, w: 0 };
    let filmActive = false;

    /* iOS does not load or decode a video nobody has asked to play, so
       it is started muted and stopped straight away. Where even that is
       refused (low power mode) the first touch does it. */
    let woken = false;
    const wakeVideo = () => {
      if (woken) return;
      const attempt = v.play();
      if (!attempt) return;
      /* The film must never run on its own: whatever happens to this
         attempt (resolved, refused, or outlived by a remount), it ends
         paused. */
      attempt
        .then(() => {
          v.pause();
          if (disposed) return;
          woken = true;
          requested = -1; // play may have moved it; seek again
          wake();
        })
        .catch(() => v.pause());
    };
    const onPlaying = () => {
      if (woken) v.pause();
    };

    const read = () => {
      rect = run.getBoundingClientRect();
      outroPx = Math.max(1, outroEl.offsetHeight);
      const d = distance.el;
      distBox = { x: d.offsetLeft, y: d.offsetTop, w: d.offsetWidth };
    };

    const write = (now: number, dt: number) => {
      const g = geometry.current;
      const span = Math.max(1, rect.height - g.h - outroPx);
      const target = clamp(-rect.top / span);
      const outroTarget = clamp((-rect.top - span) / outroPx);
      if (first) {
        cur = target;
        outro = outroTarget;
        first = false;
      }
      cur = follow(cur, target, dt, TAU);
      outro = follow(outro, outroTarget, dt, 0.08);
      const p = cur;
      const t = driveTime(p);
      timeRef.current = t;
      const o = smooth(outro);

      // ——— seek, frame-aware ———
      if (woken && !v.paused) v.pause();
      const idx = Math.min(LAST_FRAME, Math.max(0, Math.floor(t * FPS)));
      if (v.readyState >= 1 && !v.seeking && idx !== requested) {
        v.currentTime = (idx + 0.5) / FPS;
        requested = idx;
      }

      // ——— film transform: crop follows the action on narrow screens,
      //     the held frame keeps easing back by a hair, and in the outro
      //     the picture recedes inside its new frame ———
      const aspect = g.w / Math.max(1, g.h);
      const followCrop = clamp((1.3 - aspect) / 0.45);
      const room = (g.fw - g.w) / 2;
      const pan = followCrop * clamp((0.5 - focusX(t)) * g.fw, -room, room);
      const settle = smooth(clamp((p - SETTLE_RANGE[0]) / (SETTLE_RANGE[1] - SETTLE_RANGE[0])));
      const scale = (1 + SETTLE_SCALE * (1 - settle)) * (1 - 0.035 * o);
      const shift = cropped ? MOBILE.x0 * g.fw : 0;
      const filmTransform = `translate3d(${(-g.fw / 2 + pan + shift).toFixed(1)}px, ${(-g.fh / 2).toFixed(1)}px, 0) scale(${scale.toFixed(4)})`;
      put(v, "transform", filmTransform);

      // ——— outro: the frame lets go of the screen's edges ———
      const narrow = g.w <= 640 || aspect < 0.8;
      const side = (narrow ? 16 : Math.min(56, Math.max(24, g.w * 0.033))) * o;
      const top = (narrow ? 64 : 84) * o;
      const bottom = g.h * (narrow ? 0.12 : 0.1) * o;
      put(
        stage,
        "clip-path",
        o < 0.001
          ? "none"
          : `inset(${top.toFixed(1)}px ${side.toFixed(1)}px ${bottom.toFixed(1)}px)`,
      );

      // ——— type ———
      const since = now - introStart;
      let introLeft = false;
      for (const b of beats) {
        const beat = BEATS[b.id];
        let visible = 0;
        let rising = false;
        for (const line of b.lines) {
          if (line.rise >= 0 && beat.rise) {
            /* Rises from behind the fuselage: the line itself only moves;
               the block is clipped along the aircraft's top edge below. */
            const [a, z] = beat.in;
            const u = clamp((p - a - line.rise * RISE.stagger) / (z - a - 2 * RISE.stagger));
            const e = easeOut(u);
            if (e < 1) rising = true;
            visible = Math.max(visible, e);
            put(line.el, "opacity", Math.min(1, e * 7).toFixed(3));
            put(
              line.el,
              "transform",
              `translate3d(0, ${((1 - e) * RISE.depth * g.fh).toFixed(2)}px, 0)`,
            );
            continue;
          }
          for (const w of line.words) {
            let e: number;
            if (b.id === "cold") {
              e = easeOutQuart(clamp((since - w.t0) / w.dur));
              if (e < 1) introLeft = true;
            } else {
              const [a, z] = beat.rise && beat.after ? beat.after : beat.in;
              const lineStart = beat.rise ? 0 : line.delay * STAGGER;
              const step = w.char ? CHAR_STAGGER : WORD_STAGGER;
              e = easeOutQuart(clamp((p - a - lineStart - w.k * step) / (z - a)));
            }
            visible = Math.max(visible, e);
            cine(w, e);
          }
        }
        // Exit: out of focus — fades, softens ≤ 7 px, lifts ≤ 10 px. The
        // final block leaves with the outro.
        const x =
          b.id === "final"
            ? smooth(clamp(outro / 0.42))
            : beat.out
              ? smooth(clamp((p - beat.out[0]) / (beat.out[1] - beat.out[0])))
              : 0;
        const op = 1 - x;
        put(b.el, "opacity", op.toFixed(3));
        put(
          b.el,
          "transform",
          x < 0.001
            ? "none"
            : `translate3d(0, ${(-x * 10).toFixed(2)}px, 0) scale(${(1 + 0.015 * x).toFixed(4)})`,
        );
        put(b.el, "filter", x < 0.02 || op < 0.01 ? "none" : `blur(${(x * 7).toFixed(2)}px)`);
        put(b.el, "visibility", op < 0.003 || visible < 0.003 ? "hidden" : "visible");
        if (beat.rise) {
          put(b.el, "clip-path", rising && visible > 0 ? behindAircraft(t, g, pan, scale) : "none");
        }
      }
      const cueIn = easeOut(clamp((since - 2200) / 1400));
      if (cueIn < 1) introLeft = true;
      const cueOut = smooth(clamp((p - 0.004) / 0.022));
      put(cue, "opacity", (cueIn * (1 - cueOut) * 0.8).toFixed(3));
      put(soundBtn, "color", t >= BRIGHT_FROM ? "var(--air-ink)" : "var(--air-type)");
      const soundAway = smooth(clamp(outro / 0.3));
      put(soundBtn, "opacity", (1 - soundAway).toFixed(3));
      put(soundBtn, "visibility", soundAway > 0.99 ? "hidden" : "visible");

      // ——— sound ———
      soundRef.current?.update(t);
      soundRef.current?.setAudible(filmActive && !document.hidden && outro < 0.5);

      // ——— keep going only while something is still moving ———
      const waitingOnSeek = v.readyState >= 1 && idx !== requested && !v.seeking;
      return cur !== target || outro !== outroTarget || waitingOnSeek || introLeft;
    };

    /* Everything above the aircraft's top edge (in the distance block's own
       coordinates), drawn 2 px high so no letter ever paints onto the
       fuselage. */
    const behindAircraft = (
      t: number,
      g: { w: number; h: number; fw: number; fh: number },
      pan: number,
      scale: number,
    ) => {
      silhouetteAt(t, sil);
      const pts: string[] = [];
      let yFirst = 0;
      let yLast = 0;
      for (let i = SIL_X.length - 1; i >= 0; i--) {
        const x = g.w / 2 + pan + (SIL_X[i] - 0.5) * g.fw * scale - distBox.x;
        const y = g.h / 2 + (sil[i] - 0.5) * g.fh * scale - distBox.y - 2;
        if (i === SIL_X.length - 1) yLast = y;
        if (i === 0) yFirst = y;
        pts.push(`${x.toFixed(1)}px ${y.toFixed(1)}px`);
      }
      const far = 4000;
      return `polygon(${-far}px ${-far}px, ${distBox.w + far}px ${-far}px, ${distBox.w + far}px ${yLast.toFixed(1)}px, ${pts.join(", ")}, ${-far}px ${yFirst.toFixed(1)}px)`;
    };

    const removeScene = addScene(run, {
      read,
      write,
      onActive: (on) => {
        filmActive = on;
        if (!on) soundRef.current?.setAudible(false);
      },
    });
    const onVisibility = () => {
      if (document.hidden) soundRef.current?.setAudible(false);
      else wake();
    };

    // A finished seek may leave the playhead behind the scroll: catch up.
    const onSeeked = () => wake();
    const onMeta = () => {
      requested = -1;
      wake();
    };
    const onError = () => console.warn(`Could not load ${v.currentSrc || VIDEO_SRC}.`);
    v.addEventListener("seeked", onSeeked);
    v.addEventListener("playing", onPlaying);
    v.addEventListener("loadedmetadata", onMeta);
    v.addEventListener("loadeddata", onMeta);
    v.addEventListener("error", onError);
    window.addEventListener("touchend", wakeVideo, { passive: true });
    window.addEventListener("pointerup", wakeVideo, { passive: true });
    document.addEventListener("visibilitychange", onVisibility);
    wakeVideo();

    return () => {
      disposed = true;
      removeScene();
      v.removeEventListener("seeked", onSeeked);
      v.removeEventListener("playing", onPlaying);
      v.removeEventListener("loadedmetadata", onMeta);
      v.removeEventListener("loadeddata", onMeta);
      v.removeEventListener("error", onError);
      window.removeEventListener("touchend", wakeVideo);
      window.removeEventListener("pointerup", wakeVideo);
      document.removeEventListener("visibilitychange", onVisibility);
      soundRef.current?.dispose();
      soundRef.current = null;
    };
  }, [geometry, cropped]);

  const toggleSound = () => {
    const s = (soundRef.current ??= new AirSound());
    if (s.enabled) {
      s.disable();
      setSoundOn(false);
    } else {
      void s.enable(timeRef.current);
      setSoundOn(true);
    }
    // The rest of the page may speak too, if sound was asked for here.
    document.documentElement.dataset.sound = s.enabled ? "on" : "off";
  };

  return (
    <section className="air-run" ref={runRef} aria-label="Gateless, private charter" id="top">
      <div className="air-outro" ref={outroRef} aria-hidden="true" />
      <div className="air-stage" ref={stageRef}>
        <video
          ref={videoRef}
          className={`air-film${cropped ? " air-film--crop" : ""}`}
          src={cropped === null ? undefined : cropped ? MOBILE.src : VIDEO_SRC}
          poster={cropped === null ? undefined : cropped ? MOBILE.poster : FIRST_STILL}
          muted
          playsInline
          disablePictureInPicture
          disableRemotePlayback
          preload="auto"
          tabIndex={-1}
          aria-hidden="true"
        />
        <div className="air-grain" ref={grainRef} aria-hidden="true" />

        <div className="air-copy">
          <div className="air-beat air-cold" data-beat="cold">
            <Label text="Gateless" />
            <h1 className="air-head air-head--cold">
              <span data-l>
                <Split text="A different" />
              </span>
              <span data-l>
                <Split text="point of view." />
              </span>
            </h1>
          </div>

          <div className="air-beat air-inside" data-beat="inside">
            <Label text="Inside" />
          </div>

          <div className="air-beat air-distance" data-beat="distance">
            <Label text="Distance" />
            <h2 className="air-head air-head--big">
              <span data-l data-rise>
                Distance
              </span>
              <span data-l data-rise>
                changes
              </span>
              <span data-l data-rise>
                everything.
              </span>
            </h2>
          </div>

          <div className="air-beat air-final" data-beat="final">
            <Label text="Gateless" />
            <h2 className="air-head air-head--big">
              <span data-l data-delay="1">
                <Split text="The world looks" />
              </span>
              <span data-l data-delay="2">
                <Split text="different" /> <br className="air-mbr" />
                <Split text="from *here.*" />
              </span>
            </h2>
            <p className="air-note" data-l data-delay="5">
              <Split text="Private charter. No gates, no queues." />
            </p>
          </div>

          <p className="air-cue" ref={cueRef} aria-hidden="true">
            <Icon icon={ArrowDown} size={14} />
            Scroll
          </p>
        </div>

        <button
          ref={soundBtnRef}
          type="button"
          className="air-sound"
          aria-pressed={soundOn}
          onClick={toggleSound}
        >
          <Icon icon={soundOn ? Volume2 : VolumeX} size={18} />
          {soundOn ? "Sound on" : "Sound"}
        </button>
      </div>
    </section>
  );
}

/* A word comes out of focus: from soft, faint and a little apart to sharp
   and set. Letters of a label track in from wide spacing. */
function cine(w: Word, e: number) {
  const inv = 1 - e;
  const mid = (w.n - 1) / 2;
  const dx = (w.k - mid) * (w.char ? 0.14 : 0.18) * inv;
  const dy = w.char ? 0 : 0.28 * inv;
  put(w.el, "opacity", Math.pow(e, 1.25).toFixed(3));
  put(
    w.el,
    "transform",
    inv < 0.001 ? "none" : `translate3d(${dx.toFixed(3)}em, ${dy.toFixed(3)}em, 0)`,
  );
  const blur = inv * (w.char ? 4 : 11);
  put(w.el, "filter", blur < 0.05 ? "none" : `blur(${blur.toFixed(2)}px)`);
}

const easeOutQuart = (u: number) => 1 - Math.pow(1 - u, 4);

/* Words as separate spans (spaces stay real text between them). A word in
 *asterisks* is set as the serif accent. */
function Split({ text }: { text: string }) {
  const words = text.split(" ");
  return (
    <>
      {words.map((w, i) => {
        const accent = w.length > 2 && w.startsWith("*") && w.endsWith("*");
        return (
          <Fragment key={i}>
            <span className={`air-w${accent ? " accent" : ""}`} data-w>
              {accent ? w.slice(1, -1) : w}
            </span>
            {i < words.length - 1 ? " " : null}
          </Fragment>
        );
      })}
    </>
  );
}

/* A label whose letters track in; screen readers get the plain text. */
function Label({ text }: { text: string }) {
  return (
    <p className="air-label" data-l>
      <span className="air-sr">{text}</span>
      <span aria-hidden="true">
        {[...text].map((c, i) => (
          <span key={i} className="air-c" data-w>
            {c === " " ? " " : c}
          </span>
        ))}
      </span>
    </p>
  );
}

/* ——————————————— reduced motion: two held frames ——————————————— */

function CharterStill() {
  const openRef = useRef<HTMLElement>(null);
  const endRef = useRef<HTMLElement>(null);
  useFilmVars(openRef);
  useFilmVars(endRef);
  return (
    <>
      <section className="air-still" ref={openRef} id="top">
        <img
          className="air-still-img air-still-img--cabin"
          src={FIRST_STILL}
          alt="An aircraft cabin in evening light, a round window full of cloud."
        />
        <div className="air-copy">
          <div className="air-beat air-cold">
            <p className="air-label">Gateless</p>
            <h1 className="air-head air-head--cold">
              <span>A different</span>
              <span>point of view.</span>
            </h1>
          </div>
        </div>
      </section>
      <section className="air-still" ref={endRef}>
        <img
          className="air-still-img air-still-img--wide"
          src={FINAL_STILL}
          alt="A small private jet above the clouds, under a wide sky."
          loading="lazy"
        />
        <div className="air-copy">
          <div className="air-beat air-final">
            <p className="air-label">Gateless</p>
            <h2 className="air-head air-head--big">
              <span>The world looks</span>
              <span>
                different <br className="air-mbr" />
                from here.
              </span>
            </h2>
            <p className="air-note">Private charter. No gates, no queues.</p>
          </div>
        </div>
      </section>
    </>
  );
}

/* ——————————————————————— helpers ——————————————————————— */

type Geometry = { w: number; h: number; fw: number; fh: number };

/* The film's own box (16:9, covering the element), published as CSS
   variables so type can be placed in the picture's coordinates: a line set
   on the cabin wall stays on the wall when a 16:10 screen crops the sides. */
function useFilmVars(ref: RefObject<HTMLElement | null>, onChange?: () => void) {
  const geometry = useRef<Geometry>({ w: 1, h: 1, fw: 1, fh: 1 });
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const write = () => {
      const w = el.clientWidth;
      const h = el.clientHeight;
      const fw = Math.max(w, (h * 16) / 9);
      const fh = Math.max(h, (w * 9) / 16);
      geometry.current = { w, h, fw, fh };
      el.style.setProperty("--fw", `${fw}px`);
      el.style.setProperty("--fh", `${fh}px`);
      el.style.setProperty("--fl", `${(w - fw) / 2}px`);
      el.style.setProperty("--ft", `${(h - fh) / 2}px`);
      onChange?.();
    };
    write();
    const ro = new ResizeObserver(write);
    ro.observe(el);
    return () => ro.disconnect();
  }, [ref, onChange]);
  return geometry;
}

/* null until the page runs in a browser. */
function useMedia(query: string) {
  const [on, setOn] = useState<boolean | null>(null);
  useEffect(() => {
    const mq = window.matchMedia(query);
    const change = () => setOn(mq.matches);
    change();
    mq.addEventListener("change", change);
    return () => mq.removeEventListener("change", change);
  }, [query]);
  return on;
}

/* prefers-reduced-motion. The server renders the moving version; the
   still one takes over on the first pass in the browser. */
function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const on = () => setReduced(mq.matches);
    on();
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);
  return reduced;
}

const CSS = `
/* Many projects reset media to fit their box (max-width: 100%, display:
   block). Here the film and several pictures are deliberately wider than
   their box, so inside the page they keep the browser's own defaults. */
.air :where(img, video, svg) {
  display: inline;
  max-width: none;
  vertical-align: baseline;
}

/* The page after the film. */

.air .site {
  --col-gap: clamp(16px, 2vw, 32px);
  --ink-body: #3b434a;
  --rule: rgb(28 34 40 / 0.13);
  --accent-ink: #4c6a74; /* muted sky-teal, for the serif accent on paper */
  background: var(--air-paper);
  color: var(--air-ink);
}

.air .s-grid {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--col-gap);
  padding-left: var(--g);
  padding-right: var(--g);
}

/* One accent word per headline: Instrument Serif italic. */
.air .accent {
  font-family: "Instrument Serif", Georgia, serif;
  font-style: italic;
  font-weight: 400;
  font-size: 1.1em;
  line-height: 0.9;
  letter-spacing: -0.01em;
}

.air .site .accent {
  color: var(--accent-ink);
}

.air .site .v-copy .accent,
.air .site .s-sky .accent,
.air .site .s-win .accent {
  color: inherit;
}

/* ——— bar ——— */

.air .s-nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 64px;
  padding: 0 var(--g);
  background: var(--air-paper);
  transform: translate3d(0, -105%, 0);
  transition: transform 800ms cubic-bezier(0.22, 1, 0.36, 1);
}

.air .s-brand,
.air .s-nav nav a {
  font-size: 11px;
  font-weight: 560;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  text-decoration: none;
}

.air .s-brand {
  color: var(--air-ink);
}

.air .s-nav nav {
  display: flex;
  align-items: center;
  gap: clamp(22px, 3vw, 48px);
}

.air .s-nav nav a {
  color: var(--air-ink-soft);
  transition: color 300ms;
}

.air .s-nav nav a:hover,
.air .s-nav .s-nav-cta {
  color: var(--air-ink);
}

.air .s-nav .s-nav-cta {
  padding-bottom: 3px;
  border-bottom: 1px solid currentColor;
}

.air .s-brand:focus-visible,
.air .s-nav nav a:focus-visible,
.air .s-top:focus-visible,
.air .s-route-btn:focus-visible,
.air .v-cta:focus-visible {
  outline: 1px solid currentColor;
  outline-offset: 6px;
}

/* ——— type ——— */

.air .s-label {
  margin: 0 0 clamp(16px, 2.2svh, 26px);
  font-size: 11px;
  font-weight: 560;
  line-height: 1.2;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  color: var(--air-ink-soft);
}

.air .s-index-n {
  margin-right: 1.1em;
  font-size: 11px;
  letter-spacing: 0.08em;
  font-variant-numeric: tabular-nums;
  color: rgb(28 34 40 / 0.42);
}

.air .s-h2 {
  margin: 0;
  font-size: clamp(46px, 6.2vw, 112px);
  font-weight: 450;
  line-height: 0.98;
  letter-spacing: -0.045em;
}

.air .s-h3 {
  margin: 0;
  font-size: clamp(32px, 3.4vw, 62px);
  font-weight: 450;
  line-height: 1.02;
  letter-spacing: -0.045em;
}

.air .s-body {
  margin: clamp(22px, 3.2svh, 34px) 0 0;
  max-width: 36ch;
  font-size: clamp(16px, 1.12vw, 18px);
  line-height: 1.55;
  color: var(--ink-body);
}

/* A line inside its own mask; the descender room keeps g, p and y whole. */
.air .m-line {
  display: block;
  overflow: hidden;
  padding-bottom: 0.16em;
  margin-bottom: -0.16em;
}

.air .m-in {
  display: block;
}

.air .site:not(.site--still) [data-m] {
  transform: translate3d(0, 112%, 0);
  opacity: 0;
}

.air .site:not(.site--still) [data-f] {
  opacity: 0;
}

/* ——— introduction ——— */

.air .s-intro {
  padding-top: clamp(120px, 20svh, 240px);
  padding-bottom: clamp(100px, 16svh, 200px);
  row-gap: clamp(52px, 10svh, 120px);
}

.air .s-intro-label {
  grid-column: 1 / span 3;
  padding-top: 1.4em;
}

.air .s-statement {
  grid-column: 4 / span 9;
  margin: 0;
  font-size: clamp(38px, 4.8vw, 88px);
  font-weight: 450;
  line-height: 1.03;
  letter-spacing: -0.045em;
}

.air .site:not(.site--still) .s-hw {
  opacity: 0.14;
}

.air .s-intro-body {
  grid-column: 4 / span 5;
  margin: 0;
  max-width: 40ch;
}

.air .s-meta {
  grid-column: 10 / span 3;
  display: grid;
  gap: 14px;
  margin: 0;
  font-size: 14px;
}

.air .s-meta div,
.air .s-specs div {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding-top: 12px;
}

.air .s-meta dt,
.air .s-specs dt {
  font-size: 10px;
  font-weight: 560;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  color: var(--air-ink-soft);
}

.air .s-meta dd,
.air .s-specs dd {
  margin: 0;
  text-align: right;
  color: var(--ink-body);
}

/* ——— how it works: on the apron, from above ——— */

.air .s-taxi {
  height: 420svh;
}

.air .s-taxi-stage {
  background: #24282a url("/templates/private-charter/asphalt.webp") repeat;
  background-size: 640px 320px;
  color: #fff;
}

/* The ground: taller than the stage, moved under the jet. */
.air .s-taxi-ground {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 620%;
  background: url("/templates/private-charter/asphalt.webp") repeat;
  background-size: 640px 320px;
  will-change: transform;
}

.air .s-taxi-paint {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.air .s-taxi-paint polyline {
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* Rubber laid down by nose wheels, either side of the line. */
.air .s-taxi-tyres {
  stroke: rgb(8 9 11 / 0.32);
  stroke-width: 46px;
}

.air .s-taxi-line {
  stroke: #f2f3f4;
  stroke-width: 4px;
  opacity: 0.8;
}

/* Taxiway edge lights: blue. */
.air .s-taxi-light {
  position: absolute;
  width: 6px;
  height: 6px;
  margin: -3px 0 0 -3px;
  border-radius: 50%;
  background: #8fb4ff;
  box-shadow:
    0 0 6px 2px rgb(70 130 255 / 0.75),
    0 0 22px 6px rgb(60 110 255 / 0.25);
}

/* The steps, painted on the ground. */
.air .s-taxi-mark {
  position: absolute;
  font-size: clamp(64px, 11vmin, 140px);
  font-weight: 650;
  line-height: 1;
  letter-spacing: -0.04em;
  color: #f2f3f4;
  opacity: 0.7;
  transform: translateY(-50%);
}

.air .s-taxi-runway {
  position: absolute;
  bottom: 0;
  background: rgb(0 0 0 / 0.16);
  box-shadow:
    inset 4px 0 0 rgb(236 238 240 / 0.8),
    inset -4px 0 0 rgb(236 238 240 / 0.8);
}

.air .s-taxi-threshold {
  display: flex;
  justify-content: center;
  gap: 2.2%;
  height: 2.6%;
  margin-top: 1.4%;
  padding: 0 6%;
}

.air .s-taxi-threshold span {
  flex: 1;
  background: rgb(236 238 240 / 0.86);
}

.air .s-taxi-designator {
  display: block;
  margin-top: 1.6%;
  font-size: clamp(90px, 16vmin, 200px);
  font-weight: 600;
  line-height: 1;
  text-align: center;
  color: rgb(236 238 240 / 0.86);
  transform: rotate(180deg);
}

.air .s-taxi-centre {
  position: absolute;
  top: 14%;
  bottom: 0;
  left: 50%;
  width: 6px;
  margin-left: -3px;
  background: repeating-linear-gradient(180deg, rgb(236 238 240 / 0.86) 0 60px, transparent 60px 110px);
}

/* Light falls off towards the copy, and at the edges. */
.air .s-taxi-shade {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    linear-gradient(90deg, rgb(16 18 21 / 0.72) 0%, rgb(16 18 21 / 0.25) 38%, rgb(16 18 21 / 0) 55%),
    radial-gradient(120% 90% at 62% 40%, rgb(0 0 0 / 0) 50%, rgb(0 0 0 / 0.35) 100%);
}

/* The jet: the cut-out photograph is 820 × 915. */
.air .s-taxi-jet {
  position: absolute;
  top: 40%;
  left: 0;
  height: min(36vmin, 400px);
  aspect-ratio: 820 / 915;
  will-change: transform;
}

.air .s-taxi-jet picture,
.air .s-jet {
  display: block;
  width: 100%;
  height: 100%;
}

.air .s-taxi-jet--shadow .s-jet {
  filter: brightness(0);
}

.air .s-jet-light {
  position: absolute;
  width: 4px;
  height: 4px;
  margin: -2px 0 0 -2px;
  border-radius: 50%;
}

.air .s-jet-light--port {
  top: 63.5%;
  left: 3.6%;
  background: #ff5a4a;
  box-shadow: 0 0 6px 2px rgb(255 70 50 / 0.7);
}

.air .s-jet-light--star {
  top: 62.5%;
  left: 96.3%;
  background: #4ee08a;
  box-shadow: 0 0 6px 2px rgb(60 220 120 / 0.7);
}

.air .s-jet-strobe {
  background: #fff;
  box-shadow: 0 0 8px 3px rgb(255 255 255 / 0.85);
  opacity: 0;
  animation: s-strobe 1.6s infinite;
}

.air .s-jet-strobe--port {
  top: 65.5%;
  left: 3.6%;
}

.air .s-jet-strobe--star {
  top: 64.5%;
  left: 96.3%;
}

.air .s-jet-beacon {
  top: 50%;
  left: 50%;
  background: #ff3b30;
  box-shadow: 0 0 8px 3px rgb(255 59 48 / 0.7);
  animation: s-beacon 1.2s infinite;
}

@keyframes s-strobe {
  0%,
  6%,
  12%,
  100% {
    opacity: 0;
  }
  3%,
  9% {
    opacity: 1;
  }
}

@keyframes s-beacon {
  0%,
  100% {
    opacity: 0.15;
  }
  10%,
  30% {
    opacity: 1;
  }
}

.air .s-taxi-copy {
  position: absolute;
  top: 50%;
  left: var(--g);
  width: min(30vw, 420px);
  transform: translateY(-50%);
}

.air .s-taxi-copy .s-label {
  color: rgb(255 255 255 / 0.7);
}

.air .s-taxi-copy .s-h3 {
  color: #fff;
}

.air .s-taxi-copy .accent {
  color: #d9e2e6;
}

.air .s-taxi-steps {
  display: grid;
  gap: clamp(16px, 2.6svh, 26px);
  margin: clamp(28px, 5svh, 52px) 0 0;
  padding: 0;
  list-style: none;
}

.air .s-taxi-steps li {
  opacity: 0.3;
  transition: opacity 500ms;
}

.air .s-taxi-steps li.is-on {
  opacity: 0.55;
}

.air .s-taxi-steps li.is-now {
  opacity: 1;
}

/* 01 REQUEST — and one short line. */
.air .s-taxi-steps h3 {
  display: flex;
  gap: 14px;
  margin: 0;
  font-size: 12px;
  font-weight: 560;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

.air .s-taxi-steps h3 span {
  color: rgb(255 255 255 / 0.5);
}

.air .s-taxi-steps p {
  margin: 6px 0 0;
  font-size: clamp(17px, 1.3vw, 21px);
  font-weight: 400;
  letter-spacing: -0.015em;
  color: rgb(255 255 255 / 0.86);
}

/* ——— sky band ——— */

.air .s-sky {
  position: relative;
  height: 160svh;
  overflow: hidden;
  background: var(--air-paper);
}

/* The photograph, fading into the page at the bottom. */
.air .s-sky-photo {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 128svh;
  object-fit: cover;
  object-position: 50% 45%;
  transform-origin: 50% 0;
  -webkit-mask-image: linear-gradient(180deg, #000 62%, transparent 98%);
  mask-image: linear-gradient(180deg, #000 62%, transparent 98%);
}

.air .s-sky-jet {
  z-index: 2;
  pointer-events: none;
}

.air .s-sky-copy {
  position: absolute;
  z-index: 1;
  top: 22svh;
  left: var(--g);
  right: var(--g);
  color: var(--air-type);
  text-shadow: var(--air-lift);
}

.air .s-sky-title {
  margin: 0;
  font-size: clamp(64px, 12vw, 230px);
  font-weight: 450;
  line-height: 0.92;
  letter-spacing: -0.05em;
  color: var(--air-type);
}

.air .s-sky-title .accent {
  font-size: 1.12em;
}

/* ——— fleet ——— */

.air .s-fleet {
  padding: clamp(40px, 8svh, 100px) 0 clamp(80px, 12svh, 160px);
}

.air .s-fleet-head {
  padding-bottom: clamp(48px, 8svh, 100px);
}

.air .s-fleet-label {
  grid-column: 1 / span 3;
  padding-top: 1.6em;
}

.air .s-fleet-title {
  grid-column: 4 / span 9;
}

.air .s-fleet-body {
  align-items: start;
}

.air .s-fleet-sticky {
  grid-column: 1 / span 6;
  position: sticky;
  top: calc(64px + 6svh);
}

.air .s-fleet-visual {
  position: relative;
  height: min(72svh, 46vw);
  overflow: hidden;
  background: #cfd5da;
  clip-path: inset(100% 0 0 0);
}

.air .s-fleet-pic {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition:
    opacity 900ms ease,
    transform 1600ms cubic-bezier(0.22, 1, 0.36, 1);
}

.air .s-fleet-count {
  position: absolute;
  left: 20px;
  bottom: 18px;
  margin: 0;
  color: var(--air-type);
  text-shadow: var(--air-lift);
}

.air .s-fleet-list {
  grid-column: 8 / span 5;
  margin: 0;
  padding: 0;
  list-style: none;
}

.air .s-fleet-item {
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 78svh;
  opacity: 0.28;
  transition: opacity 700ms ease;
}

.air .s-fleet-item:first-child {
  opacity: 1;
}

.air .s-fleet-item-pic {
  display: none;
}

.air .s-specs {
  display: grid;
  gap: 10px;
  margin: clamp(26px, 4svh, 40px) 0 0;
  max-width: 420px;
  font-size: 14px;
}

/* ——— cutaway ——— */

.air .s-cut {
  display: grid;
  justify-items: center;
  gap: clamp(28px, 5svh, 56px);
  padding: clamp(110px, 18svh, 220px) var(--g) clamp(90px, 14svh, 180px);
  text-align: center;
}

.air .s-cut-title {
  margin: 0;
}

/* Not wider than the pictures can carry. */
.air .s-cut-frame {
  position: relative;
  display: block;
  width: min(100%, 1240px);
  aspect-ratio: 7 / 3;
  padding: 0;
  border: 0;
  overflow: hidden;
  background: #d6dbdf;
  cursor: pointer;
}

.air .s-cut-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.air .s-cut-img--in {
  opacity: 0;
  transition: opacity 1100ms cubic-bezier(0.4, 0, 0.2, 1);
}

.air .s-cut-frame.is-inside .s-cut-img--in {
  opacity: 1;
}

.air .s-cut-hint {
  margin: 0;
}

.air .s-cut-hint-touch {
  display: none;
}

@media (hover: none) {
  .air .s-cut-hint-hover {
    display: none;
  }

  .air .s-cut-hint-touch {
    display: inline;
  }
}

/* ——— destination windows ——— */

.air .s-win-section {
  height: 330svh;
}

.air .s-win-stage {
  background:
    radial-gradient(120% 80% at 50% 0%, #f2f4f5 0%, transparent 60%),
    linear-gradient(180deg, #e9ecee 0%, #dde1e4 100%);
}

.air .s-win-head {
  position: absolute;
  top: calc(64px + 5svh);
  left: var(--g);
  right: var(--g);
  z-index: 1;
  text-align: center;
}

.air .s-win-announce {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: clamp(14px, 2svh, 22px);
  padding: 8px 14px;
  border: 0;
  border-radius: 8px;
  background: rgb(28 34 40 / 0.06);
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  color: var(--air-ink);
  cursor: pointer;
  transition: background-color 250ms;
}

.air .s-win-announce:hover {
  background: rgb(28 34 40 / 0.1);
}

.air .s-win-head .s-h2 {
  font-size: clamp(46px, 5.4vw, 96px);
}

.air .s-win-track {
  position: absolute;
  bottom: 11svh;
  left: 0;
  display: flex;
  gap: clamp(72px, 8vw, 160px);
  width: max-content;
  padding: 0 0 0 40vw;
  will-change: transform;
}

/* As large as the screen allows under the headline. */
.air .s-win {
  position: relative;
  flex: 0 0 auto;
  width: clamp(220px, min(22vw, 31svh), 400px);
  aspect-ratio: 0.7;
  margin: 0;
}

/* The window frame, drawn. */
.air .s-win-frame {
  position: absolute;
  inset: 0;
  border-radius: 50% / 36%;
  background: linear-gradient(160deg, #fcfcfd 0%, #eceff1 55%, #dfe3e6 100%);
  box-shadow:
    0 44px 80px -40px rgb(30 40 50 / 0.45),
    inset 0 2px 0 rgb(255 255 255 / 0.95),
    inset 0 -4px 10px rgb(20 30 40 / 0.06);
}

.air .s-win-inner {
  position: absolute;
  inset: 7%;
  border-radius: 50% / 37%;
  background: linear-gradient(180deg, #d8dde1 0%, #f1f3f4 100%);
  box-shadow: inset 0 5px 12px rgb(20 30 40 / 0.2);
}

.air .s-win-view {
  position: absolute;
  inset: 11.5%;
  overflow: hidden;
  border-radius: 50% / 38%;
  background: #9fb3c6;
  isolation: isolate;
}

/* Wider and a little taller than the window, so it can slide behind it. */
.air .s-win-view img {
  position: absolute;
  top: -6%;
  left: -30%;
  width: 160%;
  height: 112%;
  object-fit: cover;
  will-change: transform;
}

/* The blind slides up into the frame; the window opening clips it. */
.air .s-win-blind {
  position: absolute;
  inset: 11.5%;
  overflow: hidden;
  border-radius: 50% / 38%;
  pointer-events: none;
}

.air .s-win-shade {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, #f6f7f8 0%, #e6e9ec 92%, #d9dde1 100%);
  box-shadow: inset 0 -3px 0 rgb(20 30 40 / 0.1);
}

.air .s-win-data {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  padding: 16% 10% 17%;
  color: #fff;
  text-align: center;
  text-shadow: 0 1px 2px rgb(16 24 34 / 0.18), 0 6px 26px rgb(16 24 34 / 0.28);
  /* Enough shade at the top and bottom for white type on bright views;
     rounded itself, as the view's clip does not always reach it. */
  border-radius: inherit;
  background: linear-gradient(
    180deg,
    rgb(12 18 26 / 0.3) 0%,
    rgb(12 18 26 / 0) 34%,
    rgb(12 18 26 / 0) 58%,
    rgb(12 18 26 / 0.42) 100%
  );
}

.air .s-win-arc {
  position: absolute;
  top: 9%;
  left: 14%;
  width: 72%;
  overflow: visible;
}

.air .s-win-arc path {
  fill: none;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-dasharray: 1;
}

.air .s-win-arc-track {
  stroke: rgb(255 255 255 / 0.32);
}

.air .s-win-arc-fill {
  stroke: #fff;
  stroke-dashoffset: 1;
}

.air .s-win-time,
.air .s-win-city {
  display: grid;
  gap: 4px;
  margin: 0;
}

.air .s-win-time {
  margin-top: 16%;
}

.air .s-win-time strong {
  font-size: clamp(19px, 1.6vw, 27px);
  font-weight: 500;
  letter-spacing: -0.02em;
}

.air .s-win-time span,
.air .s-win-city span {
  font-size: 10px;
  font-weight: 560;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  opacity: 0.86;
}

.air .s-win-city strong {
  font-size: clamp(22px, 2vw, 34px);
  font-weight: 450;
  line-height: 1;
  letter-spacing: -0.045em;
}

/* Fleet: each class's lines arrive one by one. */
.air .site:not(.site--still) [data-fl] {
  opacity: 0;
}

.air .site:not(.site--still) [data-wd] {
  opacity: 0;
}

/* ——— routes: large type, a picture on hover ——— */

.air .s-routes {
  position: relative;
  padding: clamp(110px, 18svh, 220px) var(--g) clamp(90px, 14svh, 180px);
}

.air .s-routes-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-bottom: clamp(28px, 5svh, 56px);
}

.air .s-routes-head .s-label,
.air .s-routes-note {
  margin: 0;
}

.air .s-routes-note {
  font-size: 13px;
  color: var(--air-ink-soft);
}

.air .s-route-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.air .s-route {
  position: relative;
}

.air .s-route-btn {
  position: relative;
  z-index: 1;
  text-decoration: none;
  display: grid;
  justify-items: start;
  gap: 8px;
  width: 100%;
  padding: clamp(10px, 1.8svh, 20px) 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.air .s-route-name {
  font-size: clamp(38px, 6.2vw, 112px);
  font-weight: 450;
  line-height: 0.94;
  letter-spacing: -0.04em;
  text-transform: uppercase;
  transition:
    transform 600ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 400ms;
}

.air .s-route-arrow {
  display: inline-block;
  margin: 0 0.22em;
  font-weight: 300;
  color: var(--accent-ink);
  transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1);
}

.air .s-route-meta {
  font-size: 11px;
  font-weight: 560;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  color: var(--air-ink-soft);
  font-variant-numeric: tabular-nums;
}

/* The other routes step back while one is pointed at. */
@media (hover: hover) {
  .air .s-route-list:hover .s-route-name {
    opacity: 0.28;
  }

  .air .s-route-list .s-route:hover .s-route-name {
    opacity: 1;
    transform: translateX(14px);
  }

  .air .s-route:hover .s-route-arrow {
    transform: translateX(8px);
  }
}

/* The picture: right, opening from the bottom. */
.air .s-route-pic {
  position: absolute;
  top: 50%;
  right: 0;
  z-index: 2;
  width: clamp(180px, 17vw, 300px);
  aspect-ratio: 4 / 5;
  overflow: hidden;
  border-radius: 4px;
  pointer-events: none;
  opacity: 0;
  clip-path: inset(100% 0 0 0);
  transform: translateY(-50%);
  transition:
    clip-path 700ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 300ms;
}

.air .s-route-pic img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(1.12);
  transition: transform 900ms cubic-bezier(0.22, 1, 0.36, 1);
}

@media (hover: hover) {
  .air .s-route:hover .s-route-pic {
    opacity: 1;
    clip-path: inset(0 0 0 0);
  }

  .air .s-route:hover .s-route-pic img {
    transform: scale(1);
  }
}

/* ——— experience ——— */

.air .s-exp {
  position: relative;
  padding-top: clamp(84px, 13svh, 160px);
  padding-bottom: clamp(20px, 4svh, 60px);
}

.air .s-exp-label {
  grid-column: 1 / span 3;
  padding-top: 1.6em;
}

.air .s-exp-title {
  grid-column: 4 / span 9;
}

/* ——— rows ——— */

.air .s-row {
  padding-top: clamp(70px, 12svh, 160px);
  padding-bottom: clamp(70px, 12svh, 160px);
  align-items: end;
}

.air .s-frame {
  position: relative;
  overflow: hidden;
  background: #d8dcdf;
  clip-path: inset(100% 0 0 0);
}

.air .s-frame .v-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.air .s-row--scope .s-frame {
  grid-column: 1 / -1;
  aspect-ratio: 2.39 / 1;
  background: #2c2620;
  clip-path: inset(50% 0);
}

.air .s-row--scope .v-img--ground {
  object-position: 50% 72%;
}

.air .s-row--scope .s-text {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--col-gap);
  align-items: end;
  margin-top: clamp(30px, 5svh, 60px);
}

.air .s-row--scope .s-label {
  grid-column: 1 / -1;
}

.air .s-row--scope .s-h3 {
  grid-column: 1 / span 6;
}

.air .s-row--scope .s-body {
  grid-column: 8 / span 4;
  margin: 0;
}

.air .s-row--portrait {
  align-items: center;
}

.air .s-row--portrait .s-frame {
  grid-column: 2 / span 5;
  aspect-ratio: 4 / 5;
  background: #2a231d;
}

.air .s-row--portrait .s-text {
  grid-column: 8 / span 4;
}

.air .s-row--portrait .v-img {
  object-position: 64% 40%;
}

.air .v-img--soft {
  opacity: 1;
}

/* ——— full screen ——— */

.air .v {
  position: relative;
}

.air .v-headon {
  height: 320svh;
}

.air .v-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100lvh;
  overflow: hidden;
  contain: layout paint;
}

.air .v-headon .v-stage {
  background: var(--air-paper);
}

.air .v-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform-origin: 50% 50%;
}

.air .v-img--far {
  transform-origin: 49% 57%; /* the aircraft */
}

.air .v-window {
  position: absolute;
  inset: 0;
  overflow: hidden;
  clip-path: inset(50% 50%);
}

.air .v-flank {
  position: absolute;
  top: 0;
  bottom: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  will-change: transform, opacity;
}

.air .v-flank .s-h3 {
  white-space: nowrap;
}

.air .v-flank--left {
  right: calc(50% + var(--w0, 30vw) / 2 + clamp(20px, 2.2vw, 40px));
  align-items: flex-end;
  text-align: right;
}

.air .v-flank--right {
  left: calc(50% + var(--w0, 30vw) / 2 + clamp(20px, 2.2vw, 40px));
}

/* Ready: details either side of the window, each closed by a hairline. */
.air .v-flank--left {
  width: min(34vw, 520px);
  align-items: stretch;
  text-align: left;
}

.air .v-flank--right {
  width: min(24vw, 340px);
}

.air .v-stats {
  display: grid;
  grid-template-columns: repeat(3, auto);
  justify-content: start;
  gap: clamp(20px, 2.4vw, 36px);
  margin: 0 0 clamp(18px, 2.6svh, 28px);
}

.air .v-stats dt {
  font-size: 10px;
  font-weight: 560;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  color: var(--air-ink-soft);
}

.air .v-stats dd {
  margin: 6px 0 0;
  font-size: clamp(20px, 1.7vw, 28px);
  font-weight: 450;
  letter-spacing: -0.035em;
  color: var(--air-ink);
}

.air .v-flank-title {
  padding-bottom: 18px;
}

.air .v-flank-text {
  margin: 0;
  padding-bottom: 18px;
  font-size: clamp(15px, 1.1vw, 17px);
  line-height: 1.55;
  color: var(--ink-body);
}

.air .v-flank-meta {
  margin: 16px 0 0;
}

.air .v-copy {
  position: absolute;
  left: var(--g);
  z-index: 2;
  color: var(--air-type);
  text-shadow: var(--air-lift);
  pointer-events: none;
}

.air .v-copy--low {
  bottom: calc(100lvh - 100svh + clamp(36px, 9svh, 110px));
}

.air .v-headon-caption {
  left: var(--g);
  right: var(--g);
  text-align: center;
}

.air .v-label {
  margin: 0 0 clamp(14px, 1.8svh, 22px);
  font-size: 11px;
  font-weight: 560;
  line-height: 1.2;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  color: var(--air-type-soft);
}

.air .v-head {
  margin: 0;
  font-size: clamp(40px, 4.8vw, 88px);
  font-weight: 450;
  line-height: 1.02;
  letter-spacing: -0.045em;
  color: var(--air-type);
}

.air .v-cta {
  display: inline-block;
  margin-top: clamp(22px, 3svh, 34px);
  padding-bottom: 4px;
  border-bottom: 1px solid currentColor;
  font-size: 11px;
  font-weight: 560;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  color: var(--air-type);
  text-decoration: none;
  pointer-events: auto;
}

/* ——— the last shot ——— */

.air .s-dusk {
  height: 200svh;
}

.air .s-dusk-stage {
  background: #2c2e55;
}

.air .s-dusk-sky {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.air .s-dusk-line {
  position: absolute;
  top: 34%;
  left: var(--g);
  right: var(--g);
  margin: 0;
  text-align: center;
  color: #fff;
  text-shadow: 0 2px 40px rgb(10 14 20 / 0.25);
  transform: translateY(-50%);
}

.air .s-dusk-line .accent {
  color: #fff;
}

/* ——— colophon ——— */

.air .s-colophon {
  position: relative;
  padding-top: clamp(110px, 18svh, 220px);
  overflow: hidden;
  background: #fafbfc;
}

.air .s-colophon-grid {
  align-items: start;
  row-gap: 40px;
}

.air .s-colophon-line {
  grid-column: 1 / span 6;
  margin: 0;
  font-size: clamp(28px, 3vw, 54px);
  font-weight: 450;
  line-height: 1.05;
  letter-spacing: -0.045em;
}

.air .s-colophon-index {
  grid-column: 8 / span 2;
  display: grid;
  gap: 9px;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 15px;
}

.air .s-colophon-index a {
  color: var(--ink-body);
  text-decoration: none;
}

.air .s-colophon-index a:hover {
  color: var(--air-ink);
}

.air .s-colophon-side {
  grid-column: 10 / span 3;
  display: grid;
  justify-items: end;
  gap: 14px;
  text-align: right;
}

.air .s-colophon-side .s-label {
  margin: 0;
}

.air .s-top {
  padding: 0;
  border: 0;
  background: none;
  font-family: inherit;
  cursor: pointer;
}

.air .s-top:hover {
  color: var(--air-ink);
}

.air .s-mark {
  display: flex;
  margin: clamp(70px, 13svh, 160px) 0 0;
  padding: 0 var(--g) clamp(10px, 2svh, 24px);
  font-size: clamp(80px, 23vw, 500px);
  font-weight: 450;
  line-height: 0.86;
  letter-spacing: -0.055em;
  white-space: nowrap;
  color: var(--air-ink);
}

/* Room inside each letter's mask for descenders and round bottoms, and at
   the sides: the tight tracking makes a glyph wider than its own box. */
.air .s-mark .m-line {
  display: inline-block;
  padding: 0.02em 0.08em 0.1em;
  margin: 0 -0.08em;
}

/* Each letter rises out of its mask on its own, one after another. */
.air .s-mark-l {
  display: block;
  transform: translate3d(0, 115%, 0) rotate(7deg);
  transform-origin: 0 100%;
  opacity: 0;
  filter: blur(10px);
  transition:
    transform 1300ms cubic-bezier(0.16, 1, 0.3, 1),
    opacity 900ms ease-out,
    filter 1100ms ease-out;
  transition-delay: calc(var(--i) * 110ms);
}

.air .s-mark.is-up .s-mark-l {
  transform: none;
  opacity: 1;
  filter: none;
}

/* The registered mark: small, raised, set off from the word. */
.air .s-mark .s-mark-reg {
  align-self: flex-start;
  margin-left: 0.06em;
  padding-top: 0.08em;
  font-size: 0.16em;
  font-weight: 400;
  letter-spacing: 0;
}

/* ——— narrow / portrait ——— */

@media (max-width: 640px), (max-aspect-ratio: 4/5) {
  .air .s-grid,
  .air .s-row--scope .s-text {
    grid-template-columns: minmax(0, 1fr);
  }

  .air .s-grid > *,
  .air .s-row--scope .s-text > * {
    grid-column: 1 / -1 !important;
    grid-row: auto !important;
  }

  .air .s-nav {
    height: 56px;
  }

  /* The terminal picture: a taller frame on a phone, edge to edge. */
  .air .s-row--scope .s-frame {
    aspect-ratio: 4 / 3;
    margin: 0 calc(-1 * var(--g));
  }

  .air .s-nav-more {
    display: none;
  }

  .air .s-intro {
    row-gap: 36px;
  }

  .air .s-intro-label,
  .air .s-fleet-label,
  .air .s-exp-label {
    padding-top: 0;
  }

  .air .s-statement {
    font-size: clamp(30px, 8.6vw, 46px);
  }

  /* Apron on a phone: the title above, the current step below. */
  .air .s-taxi {
    height: 380svh;
  }

  .air .s-taxi-shade {
    background:
      linear-gradient(180deg, rgb(16 18 21 / 0.75) 0%, rgb(16 18 21 / 0) 30%, rgb(16 18 21 / 0) 62%, rgb(16 18 21 / 0.8) 100%);
  }

  .air .s-taxi-jet {
    top: 46%;
    height: min(52vw, 34svh);
  }

  .air .s-taxi-copy {
    inset: calc(56px + 4svh) var(--g) 5svh;
    width: auto;
    display: flex;
    flex-direction: column;
    transform: none;
  }

  .air .s-taxi-steps {
    display: grid;
    margin-top: auto;
  }

  .air .s-taxi-steps li {
    grid-area: 1 / 1;
    opacity: 0;
  }

  .air .s-taxi-steps li.is-now {
    opacity: 1;
  }

  .air .s-sky {
    height: 190svh;
  }

  .air .s-sky-photo {
    height: 120svh;
    object-position: 46% 42%;
  }

  .air .s-sky-title {
    font-size: clamp(56px, 17vw, 96px);
  }



  /* Fleet: no sticky picture, each class with its own. */
  .air .s-fleet-sticky {
    display: none;
  }

  .air .s-fleet-item {
    min-height: 0;
    padding: 36px 0;
    opacity: 1 !important;
  }

  .air .s-fleet-item-pic {
    display: block;
    width: 100%;
    aspect-ratio: 4 / 3;
    object-fit: cover;
    margin-bottom: 22px;
  }

  /* Taller on a phone, centred on the cabin (both pictures alike). */
  .air .s-cut-frame {
    width: calc(100% + 2 * var(--g));
    max-width: none;
    aspect-ratio: 5 / 4;
  }

  .air .s-cut-img {
    object-position: 60% 58%;
  }

  .air .s-win-section {
    height: 360svh;
  }

  .air .s-win-track {
    padding-left: 30vw;
    gap: 56px;
  }

  .air .s-win {
    width: 64vw;
  }

  .air .s-route-name {
    font-size: clamp(30px, 9.4vw, 52px);
  }

  .air .s-routes-note {
    display: none;
  }



  .air .v-headon {
    height: 270svh;
  }

  .air .v-img--far {
    object-position: 49% 50%;
  }

  .air .v-head {
    font-size: clamp(30px, 9.4vw, 46px);
  }

  .air .v-flank--left {
    bottom: calc(50% + var(--h0, 70vw) / 2 + 18px);
  }

  .air .v-flank--right {
    top: calc(50% + var(--h0, 70vw) / 2 + 18px);
  }

  .air .v-flank--left,
  .air .v-flank--right {
    width: auto;
  }

  .air .v-stats {
    gap: 18px;
  }

  .air .s-colophon-side {
    justify-items: start;
    text-align: left;
  }
}

/* ——— reduced motion: everything still ——— */

.air .site--still .s-nav {
  transform: none;
}

.air .site--still .s-frame,
.air .site--still .v-window,
.air .site--still .s-fleet-visual {
  clip-path: none;
}

.air .site--still .v-img--soft,
.air .site--still .v-flank,
.air .site--still .s-win-blind {
  display: none;
}

.air .site--still .s-win-arc-fill {
  stroke-dashoffset: 0.4;
}

.air .site--still .v,
.air .site--still .s-sky {
  height: auto;
}

.air .site--still .s-sky {
  min-height: 100svh;
}

.air .site--still .s-sky-photo {
  height: 100%;
}

.air .site--still .v-stage {
  position: relative;
  height: 100svh;
  min-height: 540px;
}

.air .site--still .s-win-stage {
  height: auto;
  min-height: 100svh;
  padding: calc(64px + 7svh) 0 9svh;
}

.air .site--still .s-win-head {
  position: static;
  padding: 0 var(--g) 40px;
}

.air .site--still .s-win-track {
  position: static;
  overflow-x: auto;
  max-width: 100%;
  padding: 0 var(--g);
}

.air .site--still .s-fleet-sticky {
  display: none;
}

.air .site--still .s-fleet-list {
  grid-column: 1 / -1;
}

.air .site--still .s-fleet-item {
  min-height: 0;
  padding: 40px 0;
  opacity: 1;
}

.air .site--still .s-fleet-item-pic {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  margin-bottom: 22px;
}

.air .site--still .v-copy--low {
  bottom: clamp(36px, 9svh, 110px);
}

/* ——— icons ——— */

.air .site .icon {
  flex: none;
  display: inline-block;
  vertical-align: middle;
}

/* Small icons sit on the text line, a little ahead of it. */
.air .s-label .icon,
.air .v-label .icon,
.air .s-meta dt .icon,
.air .s-specs dt .icon,
.air .v-stats dt .icon {
  position: relative;
  top: -0.12em;
  margin-right: 0.7em;
  opacity: 0.85;
}

.air .s-nav-cta .icon,
.air .v-cta .icon {
  position: relative;
  top: -0.1em;
  margin-left: 0.35em;
  transition: transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
}

.air .s-nav-cta:hover .icon,
.air .v-cta:hover .icon {
  transform: translate(2px, -2px);
}



.air .s-win-time span .icon {
  position: relative;
  top: -0.08em;
  margin-right: 0.4em;
}

.air .s-top .icon {
  position: relative;
  top: -0.12em;
  margin-right: 0.6em;
}

/* Cool, light, precise. The film is the experience; everything
   here stays out of its way. */

.air {
  --air-paper: #eef0f2; /*  cool off-white */
  --air-pearl: #f5f6f5; /*  pearl white */
  --air-cloud: #dde1e4; /*  cloud grey */
  --air-silver: #a8afb5; /* brushed silver */
  --air-sky: #c9d6e1; /*    pale blue */
  --air-ink: #1c2228; /*    graphite — only where white cannot be read */
  --air-ink-soft: rgb(28 34 40 / 0.7);
  --air-type: #ffffff; /*   type over the film */
  --air-type-soft: rgb(255 255 255 / 0.84);
  /* White over pale sky measures ~2:1; a wide, faint, cool shadow gives the
     letters an edge without reading as a glow. */
  --air-lift: 0 0 1px rgb(16 24 34 / 0.12), 0 1px 3px rgb(16 24 34 / 0.1),
    0 6px 34px rgb(16 24 34 / 0.2);

  --g: clamp(24px, 3.3vw, 56px);
  --top: clamp(20px, 3.2svh, 34px);
  --toolbar: calc(100lvh - 100svh); /* iOS: part of the stage under the toolbar */

  font-family: "Hanken Grotesk", system-ui, sans-serif;
  /* Spacing is set against the browser default, not a reset's 1.5. */
  line-height: normal;
  color: var(--air-ink);
  background: var(--air-paper);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

/* ——— run & stage ——— */

.air-run {
  --outro: 80svh; /* after the held frame: the film becomes a picture */
  position: relative;
  height: calc(650svh + var(--outro));
}

.air-outro {
  position: absolute;
  left: 0;
  top: 0;
  width: 1px;
  height: var(--outro);
  visibility: hidden;
  pointer-events: none;
}

.air-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100lvh;
  overflow: hidden;
  background: var(--air-cloud);
  contain: layout paint;
  isolation: isolate;
}

.air-film {
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--fw, 100%);
  height: var(--fh, 100%);
  object-fit: cover;
  transform: translate(-50%, -50%);
  transform-origin: 50% 50%;
  will-change: transform;
  pointer-events: none;
  background: transparent;
}

/* The mobile cut: a band of the frame, laid where it sits in the whole
   picture (see MOBILE in the film). It still scales about the centre of
   the whole frame. */
.air-film--crop {
  width: calc(var(--fw, 100%) * 0.6);
  transform-origin: calc((0.5 - 560 / 1920) / 0.6 * 100%) 50%;
}

.air-grain {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-repeat: repeat;
  image-rendering: pixelated;
  transform: translateZ(0);
}

/* ——— type ——— */

.air-copy {
  position: absolute;
  inset: 0;
  pointer-events: none;
  color: var(--air-type);
}

.air-beat {
  position: absolute;
  margin: 0;
  opacity: 0;
  visibility: hidden;
}

.air-beat [data-l] {
  display: block;
}

.air-beat [data-w] {
  display: inline-block;
  opacity: 0;
}

.air-beat [data-rise] {
  opacity: 0;
  will-change: transform;
}

.air-sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.air-label {
  margin: 0 0 clamp(14px, 1.7svh, 22px);
  font-size: 11px;
  font-weight: 520;
  line-height: 1;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  color: var(--air-type-soft);
  text-shadow: var(--air-lift);
}

.air-head {
  margin: 0;
  font-weight: 450;
  line-height: 1.02;
  letter-spacing: -0.045em;
  white-space: nowrap;
  text-shadow: var(--air-lift);
}

.air-head > span {
  display: block;
}

.air-head--cold {
  font-size: min(calc(var(--fw) * 0.03), 5.8svh);
}

.air-head--big {
  font-size: min(calc(var(--fw) * 0.042), 7.4svh);
}

.air-note {
  margin: clamp(18px, 2.4svh, 28px) 0 0;
  font-size: 13px;
  font-weight: 420;
  line-height: 1.45;
  letter-spacing: 0.005em;
  color: var(--air-type-soft);
  text-shadow: var(--air-lift);
}

.air-mbr {
  display: none;
}

/* Placed in the film's coordinates (see useFilmVars), each in a part of
   the frame the picture leaves empty at that moment. */

/* Cabin: the plain wall above the seat, left of the small window. By the
   time INSIDE appears the camera has moved in and the overhead bin's shadow
   has come down, so it sits lower, on the lit part of the wall. */
.air-cold,
.air-inside {
  left: max(var(--g), calc(var(--fl) + var(--fw) * 0.105));
  top: max(calc(var(--top) + 48px), calc(var(--ft) + var(--fh) * 0.17));
}

.air-inside {
  top: max(calc(var(--top) + 48px), calc(var(--ft) + var(--fh) * 0.25));
}

/* Pull-back: open sky above and ahead of the aircraft. */
.air-distance {
  left: calc(var(--fl) + var(--fw) * 0.565);
  top: max(calc(var(--top) + 48px), calc(var(--ft) + var(--fh) * 0.085));
}

/* Wide frame: the band of sky over the small aircraft. */
.air-final {
  left: max(var(--g), calc(var(--fl) + var(--fw) * 0.06));
  top: max(calc(var(--top) + 48px), calc(var(--ft) + var(--fh) * 0.085));
}

.air-cue {
  position: absolute;
  left: var(--g);
  bottom: calc(var(--toolbar) + var(--top));
  margin: 0;
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 10px;
  font-weight: 560;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  opacity: 0;
}

/* ——— sound ——— */

.air-sound {
  position: absolute;
  top: calc(var(--top) - 10px);
  right: calc(var(--g) - 10px);
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px;
  border: 0;
  background: none;
  color: var(--air-type);
  font: inherit;
  font-size: 11px;
  font-weight: 520;
  line-height: 1;
  letter-spacing: 0.17em;
  text-transform: uppercase;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: color 700ms ease;
}

.air-sound .icon {
  flex: none;
}

.air-sound:focus-visible {
  outline: 1px solid currentColor;
  outline-offset: 2px;
}

/* ——— narrow / portrait ——— */

@media (max-width: 640px), (max-aspect-ratio: 4/5) {
  .air {
    --g: 22px;
  }

  .air-run {
    --outro: 60svh;
    height: calc(460svh + var(--outro));
  }

  .air-head--cold {
    font-size: clamp(28px, 8.6vw, 42px);
  }

  .air-head--big {
    font-size: clamp(30px, 9.4vw, 46px);
  }

  .air-label {
    font-size: 10px;
  }

  /* Portrait crop of the cabin: the window fills the middle and its bezel
     comes almost to the top, but under the console the side panel is dark
     and even (measured luma ~80). The cabin copy sits there, in pearl. */
  .air-cold,
  .air-inside {
    left: var(--g);
    top: auto;
    bottom: calc(var(--toolbar) + env(safe-area-inset-bottom, 0px) + 9svh);
  }

  /* Exterior copy sits high, in the sky above the aircraft. */
  .air-distance,
  .air-final {
    left: var(--g);
    top: calc(env(safe-area-inset-top, 0px) + 10svh);
  }

  .air-beat .air-note {
    display: none;
  }

  .air-mbr {
    display: inline;
  }

  /* The bottom of the portrait cabin crop is the dark side panel. */
  .air-cue {
    bottom: calc(var(--toolbar) + env(safe-area-inset-bottom, 0px) + 4.5svh);
  }

  .air-sound {
    top: calc(env(safe-area-inset-top, 0px) + 8px);
  }
}

/* ——— reduced motion: two held frames, no scrubbing ——— */

.air-still {
  position: relative;
  height: 100svh;
  min-height: 540px;
  overflow: hidden;
  background: var(--air-cloud);
}

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

.air-still .air-beat,
.air-still .air-beat > *,
.air-still .air-head > span {
  opacity: 1;
  visibility: visible;
}

@media (max-width: 640px), (max-aspect-ratio: 4/5) {
  .air-still-img--cabin {
    object-position: 77% 50%;
  }
  .air-still-img--wide {
    object-position: 48% 50%;
  }
}
`;

export default GatelessCharterPage;
FAQ

Frequently asked questions

A curated landing page made of matching library sections in a sensible order from hero to footer. You copy the sections one by one and assemble them with the composition snippet.

Didn't find your question? We're happy to help.

Get in touch