Undr Drift
aadmin2026-09-15 发布Site模板0 阅读

Undr Drift

类型:Site模板 · 分类:未分类 · 收录:2026-09-15 23:37:24 · 源码:会员可见

[hidecontent type="vip1"]
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Undr. Drift — an underground drift night</title>
<meta name="description" content="One closed lot, a few hours, no list, no posts. Undr. is a local underground drift night.">
<meta name="theme-color" content="#0d0e10">

<!-- Set before first paint: reveal styles key off .js, and a deferred module
     would run too late, flashing the rested state first. Keep this inline. -->
<script>document.documentElement.classList.add('js')</script>

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500&display=swap">

<!-- engine: GSAP 3.13 + Locomotive Scroll 5 (Lenis inside) — the reference's stack.
     Plain deferred scripts execute in order, before the module below. -->
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Flip.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/CustomEase.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/locomotive-scroll@5.0.0/bundled/locomotive-scroll.min.js"></script>

<style>
/* ─────────────────────────────────────────────────────────────
   new-12 · style.css
   Design system snapped from https://www.eladiodieste.com/ (2026-09-12).

   Layer order, declared once. Later layers win without specificity fights;
   anything unlayered wins over everything (keep that rare: @font-face and
   the reduced-motion block only).
   ───────────────────────────────────────────────────────────── */
@layer reset, tokens, base, layout, components, motion, utilities;

/* Inter Tight 400/500 — the web font is linked above. This is the
   metric-matched fallback so the page does not reflow when the font lands.
   Inter Tight vertical metrics: ascender 2728 / descender −680 / UPM 2816. */
@font-face {
  font-family: 'Inter Tight Fallback';
  src: local('Arial');
  ascent-override: 96.88%;
  descent-override: 24.15%;
  line-gap-override: 0%;
  size-adjust: 100%;
}

@layer reset {
  *, ::before, ::after { margin: 0; padding: 0; box-sizing: border-box; border: 0 solid; }
  html, body {
    width: 100%;
    /* house rule: no rubber-band bounce, ever */
    overscroll-behavior: none;
  }
  img, svg, video, canvas, picture { display: block; max-width: 100%; }
  img, video { height: auto; }
  button, input, select, textarea { font: inherit; letter-spacing: inherit; color: inherit; background: none; border-radius: 0; }
  button { cursor: pointer; }
  ol, ul, menu { list-style: none; }
  a { color: inherit; text-decoration: none; }
  h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; }
  :focus:not(:focus-visible) { outline: none; }
}

@layer tokens {
  :root {
    /* ── palette — three colours, from the SEMA-poster reference:
       near-black plate, red-orange for the giant headlines and small accent
       elements, white for everything you read. ── */
    --night:  #0d0e10;   /* default background */
    --red:    #e8472a;   /* accent: display type + detached details */
    --white:  #f4f4f2;   /* type */
    --black:  #000;      /* pure black: scrims, menu panel */

    --bg:          var(--night);
    --ink:         var(--white);
    --accent:      var(--red);
    --display-ink: var(--accent);      /* .t-hero / .t-display default colour */
    --bg-footer:   var(--accent);      /* body.is-footer-inview field */

    /* tints — derived, never hand-picked */
    --tint-image:  color-mix(in oklab, var(--ink) 10%, transparent);    /* reveal-image placeholder */
    --line-faint:  color-mix(in oklab, var(--ink) 20%, transparent);    /* timeline rail */
    --scrim:       color-mix(in oklab, var(--black) 50%, transparent);  /* video cover */
    --caption-ink: color-mix(in oklab, var(--ink) 80%, transparent);
    --ink-40:      color-mix(in oklab, var(--ink) 40%, transparent);    /* dimmed timeline years */

    /* ── type scale · rem on a fluid root (15px → 18px → 22px, see @layer base)
       resolved px:                       @390   @768   @1440   @1920 */
    --fs-hero:       36vw;             /* 140    —      —       —     (md: 20vw → 154 / 288 / 384) */
    --fs-display:    3rem;             /*  45     54    —       —     (md: 7rem → 122.9 / 154) */
    --fs-statement:  1.875rem;         /*  28     34    —       —     (md: 3rem  →  52.7 / 66) */
    --fs-sublead:    1.5rem;           /*  22.5   27    —       —     (md: 1.875rem → 32.9 / 41) */
    --fs-h3:         1.25rem;          /*  19     22.5  —       —     (md: 1.875rem → 32.9 / 41) */
    --fs-year:       1.125rem;         /*  17     20    —       —     (md: 1.875rem → 32.9 / 41) */
    --fs-menu:       3.75rem;          /*  56     —     —       —     (md: 7rem) */
    --fs-body-lg:    1.125rem;         /*  17     20    19.7    24.75 */
    --fs-body:       1.0625rem;        /*  16     19    18.65   23.4  */
    --fs-ui:         1rem;             /*  15     18    17.55   22    */
    --fs-caption:    .875rem;          /*  13     16    15.4    19.25 */
    --fs-meta:       .75rem;           /*  11     13.5  13.2    16.5  */

    /* line-heights */
    --lh-hero:       1;                /* + text-box trim */
    --lh-display:    1;
    --lh-display-tight: .85;
    --lh-statement:  1;
    --lh-sublead:    1;
    --lh-h3:         1.05;
    --lh-year:       1.2;
    --lh-menu:       1;
    --lh-body-lg:    1.1;
    --lh-body:       1.05;
    --lh-body-loose: 1.15;
    --lh-ui:         1.5;
    --lh-caption:    1.43;
    --lh-meta:       1.33;

    /* tracking — never positive, tighter as size grows */
    --ls-hero:       -.075em;
    --ls-display:    -.05em;
    --ls-display-tight: -.035em;
    --ls-statement:  -.04em;
    --ls-quote:      -.025em;
    --ls-sublead:    -.01em;
    --ls-h3:         -.04em;
    --ls-year:       -.025em;
    --ls-body-lg:    -.01em;
    --ls-body:       -.02em;
    --ls-credit:     -.025em;
    --ls-ui:         -.001em;
    --ls-note:       -.08em;

    /* weights — two, and 500 only where it is clickable or a micro-label */
    --w-text: 400;
    --w-ui:   500;

    /* ── easing (all four are the reference's own names) ── */
    --ease-magic:    cubic-bezier(.33, 0, 0, 1);    /* default for reveals, hovers, snap */
    --ease-out-expo: cubic-bezier(.19, 1, .22, 1);  /* word/char/divider reveals */
    --ease-quart:    cubic-bezier(.41, .35, .2, 1); /* image clip reveal, fade-in, h3 hover */
    --ease-base:     cubic-bezier(.46, .03, .12, 1);/* pill nav show/hide */
    --ease-out:      cubic-bezier(0, 0, .2, 1);     /* Tailwind ease-out, misc */

    /* durations */
    --dur-tap:    .3s;
    --dur-mid:    .6s;
    --dur-body:   .7s;    /* body color swap, fade-in */
    --dur-hover:  .75s;   /* timeline sibling dim */
    --dur-image:  1.2s;   /* reveal-clip */
    --dur-line:   1.4s;   /* reveal-lines, divider */
    --dur-word:   1.6s;   /* reveal-words / chars */

    /* reveal stagger steps (multiplied by --line / --word / --char from SplitText propIndex) */
    --step-line: 50ms;
    --step-word: 75ms;
    --step-char: 75ms;
    --step-image: .2s;    /* × --i */

    /* split masks: clip with a bleed so ascenders/descenders survive at rest */
    --split-mask-pad:     .08em;
    --split-mask-bleed-y: .3em;

    /* ── spacing (Tailwind base .25rem) ── */
    --u:        .25rem;
    --pad-x:    1rem;                 /* px-global */
    --gap-grid: 1.25rem;              /* grid-default gap-5 */
    --radius-md: .375rem;
    --rule-heavy: clamp(3px, .4vw, 8px);   /* the poster's thick underline */
    --hero-runway: 55lvh;                  /* scroll distance the hero stage stays pinned after its first screen */
    --hero-overlap: 35lvh;                 /* the next section rides up over the last part of the runway */
    --hero-scrim-top: .3;                  /* darkening over the media so the type reads: top / middle / bottom alpha */
    --hero-scrim-mid: .1;
    --hero-scrim-bottom: .4;
    --hero-video-sat: 0;                   /* the clip under the diamond raster is B&W; 1 = colour */
    --plate-scrim: .5;                     /* darkening over the Rules plates so the rule text reads */
    --header-py: 1rem;                /* md: .75rem */
    --section-pt: calc(var(--u) * 6);
    --section-pb: calc(var(--u) * 40);/* md: 72 */
    --display-mb: calc(var(--u) * 32);/* md: 56 */

    /* z-stack */
    --z-burger: 60;
    --z-pill:   40;
    --z-menu:   10;
    --z-transition-scrim: 40;
    --z-transition-panel: 50;
  }

  @media (min-width: 48rem) {
    :root {
      --fs-hero:      20vw;
      --fs-display:   7rem;
      --fs-statement: 3rem;
      --fs-sublead:   1.875rem;
      --fs-h3:        1.875rem;
      --fs-year:      1.875rem;
      --fs-menu:      7rem;
      --header-py:    .75rem;
      --section-pb:   calc(var(--u) * 72);
      --display-mb:   calc(var(--u) * 56);
    }
  }
}

@layer base {
  /* fluid root — three segments, exactly the reference's clamp() chain */
  html { font-size: 15px; }
  @media (min-width: 640px)  { html { font-size: clamp(15px, 12.9574px + .3191vw, 18px); } }
  @media (min-width: 1580px) { html { font-size: clamp(18px, 1.1765vw - .5882px, 22px); } }

  html {
    -webkit-text-size-adjust: 100%;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    -webkit-tap-highlight-color: transparent;
    tab-size: 4;
  }
  body {
    font-family: 'Inter Tight', 'Inter Tight Fallback', -apple-system, BlinkMacSystemFont, sans-serif;
    font-weight: var(--w-text);
    line-height: 1.5;
    background-color: var(--bg);
    color: var(--ink);
    transition: background-color var(--dur-body), color var(--dur-body);
  }
  /* mode: footer in view — the whole page turns accent (one token: --bg-footer) */
  body.is-footer-inview { background-color: var(--bg-footer) !important; color: var(--white) !important; }

  ::selection { background-color: var(--accent); color: var(--white); text-shadow: none; }
  html:where(html.is-changing *) { cursor: wait; }

  /* Lenis (lenis.css 1.3.17, inlined) */
  html.lenis, html.lenis body { height: auto; }
  .lenis:not(.lenis-autoToggle).lenis-stopped { overflow: clip; }
  .lenis [data-lenis-prevent], .lenis [data-lenis-prevent-wheel], .lenis [data-lenis-prevent-touch] { overscroll-behavior: contain; }
  .lenis.lenis-smooth iframe { pointer-events: none; }
  .lenis.lenis-autoToggle { transition-property: overflow; transition-duration: 1ms; transition-behavior: allow-discrete; }
}

@layer layout {
  .container, .px-global { width: 100%; padding-inline: var(--pad-x); margin-inline: auto; }
  .grid-12 { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap-grid); }
  .grid-16 { display: grid; grid-template-columns: repeat(16, minmax(0, 1fr)); gap: var(--pad-x); }
  @media (min-width: 48rem) { .grid-16 { gap: 0; } }

  .section { padding-top: var(--section-pt); }
  .section--deep { padding-bottom: var(--section-pb); }
  .h-lvh { height: 100lvh; }
  .h-svh { height: 100svh; }
  .h-dvh { height: 100dvh; }
  .h-screen { height: 100vh; height: 100svh; }
}

@layer components {
  /* ── type roles — one class per row of the type scale ── */
  .t-hero      { font-size: var(--fs-hero);      line-height: var(--lh-hero);      letter-spacing: var(--ls-hero);      font-weight: var(--w-text); color: var(--display-ink); }
  .t-display   { font-size: var(--fs-display);   line-height: var(--lh-display);   letter-spacing: var(--ls-display);   font-weight: var(--w-text); color: var(--display-ink); }
  .t-display-tight { font-size: var(--fs-display); line-height: var(--lh-display); letter-spacing: var(--ls-display-tight); font-weight: var(--w-text); color: var(--display-ink); }
  .is-ink { color: var(--ink); }      /* opt a display role back to white */
  .is-accent { color: var(--accent); }/* opt any small element into red */
  @media (min-width: 48rem) { .t-display-tight { line-height: var(--lh-display-tight); } }
  .t-statement { font-size: var(--fs-statement); line-height: var(--lh-statement); letter-spacing: var(--ls-statement); font-weight: var(--w-text); }
  .t-quote     { font-size: var(--fs-statement); line-height: var(--lh-statement); letter-spacing: var(--ls-quote);     font-weight: var(--w-text); }
  .t-sublead   { font-size: var(--fs-sublead);   line-height: var(--lh-sublead);   letter-spacing: var(--ls-sublead);   font-weight: var(--w-text); }
  .t-h3        { font-size: var(--fs-h3);        line-height: var(--lh-h3);        letter-spacing: var(--ls-h3);        font-weight: var(--w-ui); }
  .t-year      { font-size: var(--fs-year);      line-height: var(--lh-year);      letter-spacing: var(--ls-year);      font-weight: var(--w-ui); }
  .t-menu      { font-size: var(--fs-menu);      line-height: var(--lh-menu);      letter-spacing: 0;                   font-weight: var(--w-text); }
  .t-body-lg   { font-size: var(--fs-body-lg);   line-height: var(--lh-body-lg);   letter-spacing: var(--ls-body-lg);   font-weight: var(--w-text); }
  .t-body      { font-size: var(--fs-body);      line-height: var(--lh-body);      letter-spacing: var(--ls-body);      font-weight: var(--w-text); }
  .t-body-loose{ font-size: var(--fs-body);      line-height: var(--lh-body-loose);letter-spacing: 0;                   font-weight: var(--w-text); }
  .t-credit    { font-size: var(--fs-body);      line-height: var(--lh-body);      letter-spacing: var(--ls-credit);    font-weight: var(--w-text); }
  .t-ui        { font-size: var(--fs-ui);        line-height: var(--lh-ui);        letter-spacing: var(--ls-ui);        font-weight: var(--w-ui); }
  .t-ui-lg     { font-size: var(--fs-body-lg);   line-height: 1.556;               letter-spacing: 0;                   font-weight: var(--w-ui); }
  .t-caption   { font-size: var(--fs-caption);   line-height: var(--lh-caption);   letter-spacing: 0;                   font-weight: var(--w-ui); }
  .t-meta      { font-size: var(--fs-meta);      line-height: var(--lh-meta);      letter-spacing: 0;                   font-weight: var(--w-text); }
  .t-note      { font-size: .5em;                line-height: 1;                   letter-spacing: var(--ls-note);      opacity: .75; display: block; margin-top: .5rem; }

  /* trims the half-leading above cap height and below the baseline —
     nav, hero words and years sit on the grid instead of in a line box */
  .text-trim { text-box: trim-both cap alphabetic; }
  .balance { text-wrap: balance; }

  /* ── hairline + the heavy rule ── */
  .divider { background-color: currentColor; width: 100%; height: 1px; }
  .rule-heavy { background-color: var(--accent); width: 100%; height: var(--rule-heavy); }

  /* ── burger — the only fixed chrome. Two bars = the poster's "=" glyph; accent
     by default, white on the footer field; morphs into a cross while open ── */
  .burger {
    position: fixed; top: 1rem; right: var(--pad-x); z-index: var(--z-burger);
    display: flex; flex-direction: column; justify-content: space-between;
    width: 1.75rem; height: 1.125rem; padding: 0;
    color: var(--accent); transition: opacity var(--dur-tap), color var(--dur-body);
  }
  @media (min-width: 48rem) { .burger { top: 2rem; } }
  .burger span {
    display: block; height: 2px; width: 100%; background: currentColor;
    transition: translate var(--dur-tap) var(--ease-magic), rotate var(--dur-tap) var(--ease-magic);
  }
  .burger[aria-expanded="true"] span:first-child { translate: 0 .5rem; rotate: 45deg; }
  .burger[aria-expanded="true"] span:last-child  { translate: 0 -.5rem; rotate: -45deg; }
  .burger[aria-expanded="true"] { color: var(--ink); }
  body.is-footer-inview .burger { color: var(--white); }
  @media (hover: hover) { .burger:hover { opacity: .6; } }

  /* nav row (kept for inner pages / the pill; no longer on the home hero) */
  .nav {
    display: flex; align-items: center; justify-content: space-between; gap: 2rem;
    padding-block: var(--header-py);
    font-weight: var(--w-ui);
  }
  .nav__link { display: inline-flex; }

  /* nav link with the dot: hover → dot grows in, label slides right;
     aria-current="true" → dot parked, label parked */
  .nav__link {
    align-items: center; gap: .5em;
    text-box: trim-both cap alphabetic;
  }
  .nav__link::before {
    content: ""; display: block; width: .625em; height: .625em; flex-shrink: 0;
    border-radius: 50%; background-color: transparent; transform: scale(0);
    transition: background-color var(--dur-tap) ease-in-out, transform var(--dur-tap) var(--ease-magic);
  }
  .nav__link > span {
    display: inline-block; translate: -1.125em 0;
    transition: translate var(--dur-tap) var(--ease-magic);
  }
  .nav__link--static > span { translate: 0; }
  @media (hover: hover) {
    .nav__link:hover::before { transform: scale(1); background-color: var(--accent); }
    .nav__link:hover > span { translate: 0; }
  }
  .nav__link[aria-current="true"]::before { transform: scale(1); background-color: var(--accent); }
  .nav__link[aria-current="true"] > span { translate: 0; }

  /* ── plain links ── */
  .link-dim { transition: opacity var(--dur-tap); }
  @media (hover: hover) { .link-dim:hover { opacity: .6; } }

  /* ── image credit (bottom-right of a reveal-image) ── */
  .credit {
    position: absolute; right: .5rem; bottom: .5rem;
    color: var(--caption-ink); font-weight: var(--w-ui);
    border-radius: var(--radius-md);
    filter: drop-shadow(0 25px 25px rgb(0 0 0 / .15));
    opacity: 0; transition: opacity var(--dur-tap);
  }
  .credit.is-inview { opacity: 1; }
  @media (min-width: 48rem) { .credit { right: 1rem; bottom: 1rem; } }

  /* ── mobile menu panel — GSAP drives clip-path; CSS holds the rest ── */
  .menu {
    position: fixed; top: 0; bottom: 1.5rem; left: 0; z-index: var(--z-menu);
    height: 100dvh; width: 100%; padding: 1rem; text-align: center;
    background: var(--black); color: var(--ink);
    visibility: hidden; pointer-events: auto;
  }
  .menu__inner { display: flex; height: 100%; flex-direction: column; justify-content: flex-end; }
  .menu__inner > nav { margin-block: auto; }     /* list floats to the vertical centre, footer stays down */
  .menu__list { display: flex; flex-direction: column; text-align: left; }
  .menu__list li { overflow: hidden; }
  .menu__list a { display: block; border-top: 1px solid var(--ink); padding: 1rem 0 1.25rem; }
  @media (min-width: 48rem) { .menu { padding: 2rem var(--pad-x); } .menu__list a { padding: 1.25rem 0 1.5rem; } }
  .menu__footer { display: flex; flex-direction: column; gap: 1rem; text-align: left; }

  /* ── project pill nav ── */
  .pill-nav {
    pointer-events: none; position: fixed; inset-inline: 0; bottom: 1rem; z-index: var(--z-pill);
    display: flex; justify-content: center;
    transition: translate .5s var(--ease-base);
  }
  .pill-nav[data-hidden="true"] { translate: 0 calc(100% + 1.5rem); }
  .pill-nav__box {
    pointer-events: auto; position: relative; height: 2.5rem; width: 11.75rem;
    overflow: clip; background: var(--ink); color: var(--night);
  }
  .pill-nav__cta {
    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    transition: opacity var(--dur-tap);
  }
  @media (hover: hover) { .pill-nav__cta:hover { opacity: .6; } }

  /* ── footer wordmark: an HTML block cut to the glyphs by an SVG clipPath; inside — the B&W
     still + its raster in a frame twice as tall that parallaxes on scroll. currentColor (white on
     the accent field) shows until the image arrives ── */
  .wordmark { position: relative; width: 100%; aspect-ratio: 1408 / 285; overflow: hidden; clip-path: url(#wordmark-clip); background: currentColor; }
  .wordmark__frame { position: absolute; inset-inline: 0; top: 0; height: 200%; }
  .wordmark img { display: block; width: 100%; height: 100%; object-fit: cover; filter: saturate(0); }

  /* ── timeline → speedometer ──
     The reference's centre rail + progress line became an arc: a sticky
     full-viewport layer holds one SVG (viewBox = viewport, set by JS) with the
     faint track, the accent progress (pathLength 1, dashoffset 1 → 0) and the
     needle (rotate −180° → 0° around the bottom centre). It sticks for the
     section's whole height and takes no flow space (negative bottom margin). */
  .timeline { position: relative; min-height: 100vh; overflow-x: clip; display: grid; }
  .timeline__speedo, .timeline__body { grid-area: 1 / 1; }          /* one cell: the layer overlays the body, adds no height */
  .timeline__body { position: relative; }   /* the section's own padding sits OUTSIDE .timeline, so the sticky dial ends with the plates */
  .timeline__speedo {
    pointer-events: none; position: sticky; top: 0; z-index: 20; align-self: start;
    height: 100lvh; margin-inline: calc(-1 * var(--pad-x));         /* edge to edge, past px-global */
  }
  .timeline__speedo svg { display: block; width: 100%; height: 100%; overflow: visible; }
  .timeline__rail, .timeline__progress { fill: none; stroke-width: 1; vector-effect: non-scaling-stroke; }
  .timeline__rail { stroke: var(--line-faint); }
  .timeline__progress { stroke: var(--accent); stroke-dasharray: 1; stroke-dashoffset: 1; }
  .timeline__needle { fill: var(--accent); }           /* a tapered block (points set by JS), not a stroke */
  .timeline__dial { position: absolute; bottom: 1.25rem; color: var(--ink-40); }   /* .t-year — the size the rail's dates had */
  .timeline__dial--0 { left: var(--pad-x); }
  .timeline__dial--max { right: var(--pad-x); }
  .timeline__grid { position: relative; grid-auto-rows: auto; padding-bottom: 1.5rem; }
  /* each item declares its row and 16-col span inline (--row / --col);
     below md everything spans the full width */
  .timeline__cell { position: relative; grid-row-start: var(--row, auto); grid-column: 1 / -1; }
  @media (min-width: 48rem) { .timeline__cell { grid-column: var(--col, 1 / -1); } }
  .timeline__num { display: block; margin-bottom: .5rem; color: var(--accent); }   /* rule number over the text */
  .timeline__item { position: relative; display: block; padding-bottom: 1.5rem; transition: opacity var(--dur-hover) var(--ease-magic); }
  @media (min-width: 48rem) {
    .timeline__item { padding-bottom: 0; }
    .timeline:has(.timeline__item:hover) .timeline__item { opacity: .3; }
    .timeline:has(.timeline__item:hover) .timeline__item:hover { opacity: 1; }
  }
  .timeline__media {
    margin-inline: auto; width: 50%; overflow: hidden; background: var(--tint-image);
    clip-path: inset(0%); transition: clip-path var(--dur-body) var(--ease-magic);
  }
  @media (min-width: 48rem) {
    .timeline__media { margin-inline: 0; width: 100%; }
    .timeline__item:hover .timeline__media { clip-path: inset(5%); }
  }
  .timeline__reveal { position: relative; height: 100%; width: 100%; background-size: cover; background-position: center; }
  /* the frame is what the parallax moves; the still is B&W under its raster, like the hero clip */
  .timeline__frame { position: absolute; inset: 0; }
  .timeline__reveal img { height: 100%; width: 100%; object-fit: cover; opacity: 0; transition: opacity .5s; filter: saturate(0); }
  .timeline__reveal img.is-loaded { opacity: 1; }
  /* scrim above image + raster: the white rule text sits partly over the plate */
  .timeline__reveal::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: rgb(13 14 16 / var(--plate-scrim)); }
  .timeline__title { margin-top: 2rem; text-align: center; }
  @media (min-width: 48rem) {
    .timeline__title { position: absolute; top: 50%; margin-top: 0; translate: 0 -50%; }
    .timeline__title--left  { left: -20%; text-align: left;  max-width: var(--timeline-title-safe-w, 43ch); }
    .timeline__title--right { right: -20%; text-align: right; max-width: var(--timeline-title-safe-w, 45ch); }
    .timeline__title--right.is-edge { right: -40%; }
  }

  /* ── stack ── */
  .stack { position: relative; height: var(--stack-height); }
  @media (min-width: 48rem) { .stack { height: 100vh; } }
  .stack__viewport { position: sticky; top: 0; height: 100dvh; }
  @media (min-width: 48rem) { .stack__viewport { height: 100%; } }
  .stack__slide { position: absolute; inset: 0; display: flex; flex-direction: column; overflow: hidden; }
  .stack__media { position: relative; height: 60%; width: 100%; flex-shrink: 0; overflow: hidden; will-change: transform; background: var(--night); }   /* opaque under a lazy image */
  .stack__media-inner { position: absolute; inset: 0; will-change: transform; }
  .stack__media img { height: 100%; width: 100%; object-fit: cover; }
  .stack__caption {
    display: flex; height: 40%; flex-shrink: 0; flex-direction: column; overflow: auto; padding: 1rem;
    background: var(--bg); color: var(--ink); line-height: 1.2em; font-weight: var(--w-ui); letter-spacing: -.025em;
    will-change: transform;
  }
  .stack__caption > div { margin-block: auto; display: flex; flex-direction: column; gap: 1rem; }
  .stack__layer { position: absolute; inset: 0; background: transparent; }
  @media (min-width: 48rem) {
    .stack__slide { flex-direction: row; align-items: center; justify-content: center; }
    .stack__media { position: absolute; inset: 0; height: auto; }
    .stack__media img { height: 130%; }
    .stack__caption { position: absolute; bottom: 0; left: 0; height: auto; max-width: 36rem; background: transparent; color: var(--white); }
    .stack__layer { background: var(--black); }
  }

  /* ── hero stage ──
     250lvh section, 100lvh sticky stage: the media layer (scroll-scrubbed
     video) sits in .hero__scene. The chrome is the poster composition:
     "Undr." + heavy rule + three-word row top-left, date + time across the
     vertical centre with a red "+", note bottom-left, "Drift" bottom-right. */
  .hero { position: relative; height: calc(100lvh + var(--hero-runway)); visibility: hidden; color: var(--ink); }
  .hero__sticky { position: sticky; top: 0; height: 100lvh; overflow: hidden; }
  .hero__scene { position: absolute; inset: 0; overflow: hidden; }
  .hero__layers { position: absolute; inset: 0; }
  .hero__media { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  /* diamond raster: a WebGL canvas over the clip; the clip itself goes B&W
     and shows through at 27%. Both hang on .has-fx — FX off = neither */
  .hero.has-fx .hero__media { filter: saturate(var(--hero-video-sat)); }
  .raster { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }   /* hero + plates */
  /* scrim: the type must read over any frame of the clip; two tokens, zero to disable */
  .hero__layers::after {
    content: ""; position: absolute; inset: 0; pointer-events: none;
    background: linear-gradient(180deg,
      rgb(13 14 16 / var(--hero-scrim-top)) 0%, rgb(13 14 16 / var(--hero-scrim-mid)) 30%,
      rgb(13 14 16 / var(--hero-scrim-mid)) 70%, rgb(13 14 16 / var(--hero-scrim-bottom)) 100%);
  }
  /* the section after the hero starts before the runway ends, so its statement
     lands on the video while the scene is fading to the night plate */
  .hero-overlap { position: relative; z-index: 1; margin-top: calc(-1 * var(--hero-overlap)); }
  .hero__chrome { pointer-events: none; position: absolute; inset-inline: 0; top: 0; height: 100svh; overflow: hidden; }
  .hero__word { display: inline-block; white-space: nowrap; }
  .hero__word--right { text-align: right; }

  .hero__top { position: absolute; inset-inline: 0; top: 0; padding-top: 2rem; }
  .hero__underline { display: inline-block; }                /* the rule takes the word's width */
  .hero__underline .rule-heavy { margin-top: clamp(4px, .5vw, 10px); }
  .hero__row { display: flex; flex-direction: column; gap: .25rem; margin-top: 1.25rem; }
  .hero__row > * { display: inline-block; }
  @media (min-width: 48rem) {
    .hero__top { padding-top: 2rem; }
    .hero__underline { margin-left: -1.5rem; }               /* optical: the "U" hugs the gutter */
    .hero__underline .rule-heavy { margin-left: 1.5rem; width: calc(100% - 1.5rem); }
    .hero__row { flex-direction: row; justify-content: space-between; gap: 2rem; }
  }

  .hero__mid {
    position: absolute; inset-inline: 0; top: 50%; translate: 0 -50%;
    display: flex; align-items: center; justify-content: space-between;
  }
  .hero__mid > span { display: inline-block; }
  .hero__plus { color: var(--accent); font-size: calc(var(--fs-statement) * 1.6); line-height: 1; }   /* reads as a glyph, like the poster's "=" */
  @media (max-width: 47.99rem) {
    .hero__mid .t-statement { font-size: var(--fs-sublead); letter-spacing: var(--ls-sublead); }
    .hero__mid .hero__plus { font-size: calc(var(--fs-sublead) * 1.6); }
  }

  .hero__bottom {
    position: absolute; inset-inline: 0; bottom: 0; padding-bottom: 2rem;
    display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem;
  }
  .hero__note { padding-bottom: .1em; }
  .hero__coords { white-space: nowrap; }
  /* redacted digits: blurred in place, still selectable-looking but unreadable */
  .is-redacted { display: inline-block; filter: blur(3px); user-select: none; }

  /* placeholder plate for images that do not exist yet — same tint the
     reveal-image plate uses, so the composition reads without assets */
  .ph { background: color-mix(in oklab, var(--ink) 10%, var(--night)); }   /* opaque, so stacked plates do not bleed through */
  .reveal-image .ph { background: color-mix(in oklab, var(--ink) 18%, var(--night)); }
  .ph--accent { background: color-mix(in oklab, var(--accent) 70%, var(--night)); }

  /* ── three columns of images ── */
  .three-images { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap-grid); }
  .three-images > * { grid-column: span 12; }
  @media (min-width: 48rem) { .three-images > * { grid-column: span 4; } }

  /* ── page transition chrome ── */
  .transition-scrim { pointer-events: none; position: fixed; inset: 0; z-index: var(--z-transition-scrim); background: var(--black); opacity: 0; }
  .transition-panel { position: fixed; inset-inline: 0; top: 0; z-index: var(--z-transition-panel); height: 100lvh; background: var(--black); clip-path: inset(0 0 100% 0); }
  .transition-panel img { position: absolute; inset: 0; height: 100%; width: 100%; object-fit: cover; opacity: 0; }

  /* ── video cover ── */
  .video-cover { position: absolute; inset: 0; transition: opacity var(--dur-body); }
  .video-cover video { position: absolute; inset: 0; height: 100%; width: 100%; object-fit: cover; filter: saturate(80%); transition: filter 1s; }
  .video-cover__scrim { position: absolute; inset: 0; background: var(--scrim); transition: opacity 1s; }
  .video-cover__play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 2.5rem; transition: transform var(--dur-tap) var(--ease-magic); }
  .video-cover__play svg { width: 2.5rem; }
  @media (min-width: 48rem) { .video-cover__play svg { width: 5.25rem; } }
  @media (hover: hover) {
    .video-cover:hover video { filter: saturate(100%); }
    .video-cover:hover .video-cover__scrim { opacity: 0; }
    .video-cover__play:hover { transform: scale(1.4); }
  }
}

@layer motion {
  /* ═══════════════════════════════════════════════════════════════
     The reveal contract.
     JS toggles ONE class — `.is-inview` (Locomotive Scroll `data-scroll`) —
     and adds `.is-loaded` on images. Everything else is CSS: the index each
     SplitText unit carries (`--line`, `--word`, `--char`, set by propIndex)
     is the stagger.
     `:where(html.js *)` — with JS off nothing is ever hidden.
     ═══════════════════════════════════════════════════════════════ */

  /* lines rise 1em and fade in, 50ms apart */
  .reveal-lines:where(html.js *) { opacity: 0; }
  .reveal-lines.is-inview { opacity: 1; }
  .reveal-lines .line {
    translate: 0 1em; opacity: 0;
    transition-property: translate, transform, opacity;
    transition-duration: var(--dur-line);
    transition-timing-function: var(--ease-magic);
  }
  .reveal-lines.is-inview .line {
    translate: 0 0; opacity: 1;
    transition-delay: calc(var(--line, 0) * var(--step-line));
  }

  /* words / chars slide up out of a mask, 75ms apart */
  .reveal-words:where(html.js *), .reveal-chars:where(html.js *) { opacity: 0; }
  .reveal-words.is-inview, .reveal-chars.is-inview { opacity: 1; }
  .reveal-words .word {
    transition: var(--dur-word) var(--ease-out-expo) transform;
    transition-delay: calc(var(--step-word) * var(--word, 0));
    transform: translateY(130%);
  }
  .reveal-chars .char {
    transition: var(--dur-word) var(--ease-out-expo) transform;
    transition-delay: calc(var(--step-char) * var(--char, 0));
    transform: translateY(130%);
  }
  .reveal-words.is-inview .word, .reveal-chars.is-inview .char { transform: translateY(0%); }

  /* hairline draws from the left */
  .reveal-divider { transition: transform var(--dur-line) var(--ease-out-expo); transform-origin: 0; transform: scaleX(0); }
  .reveal-divider.is-inview { transform: scaleX(1); }

  /* image: tinted plate, then clip-up + settle once in view AND loaded */
  .reveal-image { background-color: var(--tint-image); overflow: hidden; }
  .reveal-image img, .reveal-image video, .reveal-image .ph { opacity: 0; }
  .reveal-image.is-inview.is-loaded img, .reveal-image.is-inview.is-loaded video, .reveal-image.is-inview.is-loaded .ph {
    opacity: 1;
    animation: reveal-clip var(--dur-image) var(--ease-quart) both;
    animation-delay: calc(var(--i, 0) * var(--step-image));
  }
  @keyframes reveal-clip {
    0%  { clip-path: inset(0 0 100%); transform: scale(1.1) translateY(-5%); }
    to  { clip-path: inset(0 0 0%);   transform: scale(1); }
  }
  @keyframes fade-in { 0% { opacity: 0; } to { opacity: 1; } }
  .animate-fade-in { animation: fade-in var(--dur-body) var(--ease-quart) forwards; }

  /* SplitText masks — GSAP wraps each unit in `.<unit>-mask`; we swap its
     overflow:clip for a clip-path with vertical bleed so glyph extenders
     are not shaved at rest, and pad the right edge so italics/overhangs fit. */
  .line-mask { display: block; }
  .char-mask, .word-mask, .line, .word, .char { display: inline-block; }
  .word-mask, .char-mask { padding-right: var(--split-mask-pad); margin-right: calc(var(--split-mask-pad) * -1); }
  .line-mask, .word-mask, .char-mask {
    clip-path: inset(calc(var(--split-mask-bleed-y) * -1) 0 calc(var(--split-mask-bleed-y) * -1) 0);
    overflow: visible !important;
  }

  /* generic inview fade (credits, small labels) */
  .inview-fade { opacity: 0; transition: opacity var(--dur-tap); }
  .inview-fade.is-inview { opacity: 1; }
}

@layer utilities {
  .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; }
  .invisible { visibility: hidden; }
  .hidden { display: none; }
  .relative { position: relative; }
  .overflow-clip { overflow: clip; }
  .pointer-none { pointer-events: none; }
  .pointer-auto { pointer-events: auto; }
  .md-only { display: none; }
  @media (min-width: 48rem) { .md-only { display: block; } .mobile-only { display: none; } }
  .w-full { width: 100%; }
  .object-cover { object-fit: cover; width: 100%; height: 100%; }
  .aspect-3-2 { aspect-ratio: 3 / 2; }
  .aspect-4-3 { aspect-ratio: 4 / 3; }
  .aspect-16-9 { aspect-ratio: 16 / 9; }
  .mt-2 { margin-top: calc(var(--u) * 2); }
  .mt-5 { margin-top: calc(var(--u) * 5); }
  .mt-6 { margin-top: calc(var(--u) * 6); }
  .mt-8 { margin-top: calc(var(--u) * 8); }
  .mt-24 { margin-top: calc(var(--u) * 24); }
  .mb-5 { margin-bottom: calc(var(--u) * 5); }
  .mb-6 { margin-bottom: calc(var(--u) * 6); }
  .mb-display { margin-bottom: var(--display-mb); }
  .pt-6 { padding-top: calc(var(--u) * 6); }
  .pb-6 { padding-bottom: calc(var(--u) * 6); }
  .pt-24 { padding-top: calc(var(--u) * 24); }
  .pb-43 { padding-bottom: calc(var(--u) * 43); }
  .py-8 { padding-block: calc(var(--u) * 8); }
  .gap-5 { gap: calc(var(--u) * 5); }
  .col-span-4 { grid-column: span 4 / span 4; }
  .col-span-7 { grid-column: span 7 / span 7; }
  .col-span-12 { grid-column: span 12 / span 12; }
  @media (min-width: 48rem) {
    .md:col-span-7 { grid-column: span 7 / span 7; }
    .md:col-start-6 { grid-column-start: 6; }
    .md:col-span-5 { grid-column: span 5 / span 5; }
    .md:col-span-6 { grid-column: span 6 / span 6; }
    .md:col-start-7 { grid-column-start: 7; }
    .md:max-w-66 { max-width: 66%; }
  }
  .text-right { text-align: right; }
  .flex { display: flex; }
  .items-end { align-items: flex-end; }
  .items-center { align-items: center; }
  .justify-between { justify-content: space-between; }
  .flex-col { flex-direction: column; }
  .gap-2 { gap: calc(var(--u) * 2); }
  .gap-4 { gap: calc(var(--u) * 4); }
}

/* ── reduced motion: every reveal rests in its final state, scrub-driven
   scenes are gated in JS with gsap.matchMedia ── */
@media (prefers-reduced-motion: reduce) {
  .reveal-lines .line, .reveal-words .word, .reveal-chars .char { transition: none; translate: 0 0; transform: none; opacity: 1; }
  .reveal-lines:where(html.js *), .reveal-words:where(html.js *), .reveal-chars:where(html.js *) { opacity: 1; }
  .reveal-divider { transition: none; transform: scaleX(1); }
  .reveal-image img, .reveal-image video, .reveal-image .ph { opacity: 1; animation: none; }
  .burger, .burger span, .pill-nav, .nav__link::before, .nav__link > span, .timeline__item, .timeline__media { transition: none; }
}
</style>
</head>
<body>

<!-- ═══ burger: the only fixed chrome. Two bars (the poster's "=" motif), accent,
     morphs into a cross while the full-screen menu is open ═══ -->
<button class="burger" data-menu-toggle aria-label="Menu" aria-expanded="false" aria-controls="menu">
  <span></span><span></span>
</button>

<!-- ═══ mobile menu panel ═══ -->
<div class="menu" id="menu" data-menu>
  <div class="menu__inner">
    <nav aria-label="Menu">
      <ul class="menu__list t-menu">
        <li><a class="text-trim" href="#rules">Rules</a></li>
        <li><a class="text-trim" href="#nights">Nights</a></li>
        <li><a class="text-trim" href="#pull-up">Pull up</a></li>
      </ul>
    </nav>
    <div class="menu__footer t-body-lg">
      <p>Undr. Drift © <span data-year-now>2026</span></p>
      <div class="flex items-center justify-between gap-2">
        <a href="#rules" class="link-dim">Rules</a>
        <a href="#" class="link-dim">Drop</a>
      </div>
    </div>
  </div>
</div>

<main id="top">

  <!-- ═══ HERO · 250lvh runway, 100lvh sticky stage.
       Media (scroll-scrubbed video) mounts into [data-scene]; the hero module
       plays the intro over it and feeds 0→1.5 to the media hook.
       Composition (poster): "Undr." top-left with a heavy rule under it, a row of
       three words, date + time across the vertical centre with a red "+",
       entry/location note bottom-left, "Drift" bottom-right. ═══ -->
  <h1 class="sr-only">Undr. Drift — underground drift session</h1>
  <section class="hero" data-hero>
    <div class="hero__sticky">
      <div class="hero__scene" data-scene-wrapper>
        <div class="hero__layers" data-scene aria-hidden="true">
          <!-- all-intra re-encode (ffmpeg -g 1) so every frame seeks instantly, hosted on CloudFront
               (CORS + byte ranges); crossorigin so the raster can read its frames -->
          <video class="hero__media" data-hero-video muted playsinline preload="auto" crossorigin="anonymous"
                 poster="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/c34fa15e-fad4-4c37-a7a0-1a325b39e8bf.jpg" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/3abfc4bb-a49d-4a22-93e6-3ebb0f7fa9ac.mp4"></video>
        </div>
      </div>
    </div>
    <div class="hero__chrome" aria-hidden="true">
      <div class="px-global hero__top">
        <div class="hero__underline">
          <div class="t-hero text-trim"><span class="hero__word" data-line="1">Undr.</span></div>
          <div class="rule-heavy reveal-divider" data-hero-rule></div>
        </div>
        <div class="hero__row t-ui" data-hero-row>
          <span>Late night</span>
          <span>Closed lot</span>
          <span>Sideways</span>
        </div>
      </div>
      <div class="px-global hero__mid" data-hero-mid>
        <span class="t-statement text-trim" data-hero-date>23.04</span>
        <span class="t-statement text-trim hero__plus" data-hero-plus>+</span>
        <span class="t-statement text-trim" data-hero-time>00:30</span>
      </div>
      <div class="px-global hero__bottom">
        <p class="t-meta text-trim hero__note" data-hero-note>Secret location <span class="hero__coords">55.7<span class="is-redacted">558</span>, 37.6<span class="is-redacted">173</span></span></p>
        <div class="t-hero text-trim hero__word--right"><span class="hero__word" data-line="2">Drift</span></div>
      </div>
    </div>
  </section>

  <!-- ═══ STATEMENT · one paragraph at 3rem, lines rising 50ms apart; rides up over the end of the hero runway ═══ -->
  <section class="px-global pt-6 hero-overlap">
    <h2 class="t-statement reveal-lines md:max-w-66" data-split="lines" data-scroll data-scroll-offset="20%">
      One closed lot, no lights but the city’s. Cars that have no business being this
      sideways, and a crowd that knows not to post. Undr. is a drift night that exists
      for a few hours and then doesn’t.
    </h2>
  </section>

  <!-- ═══ BODY · 12-column grid, one paragraph in the right five columns ═══ -->
  <section class="px-global pt-24">
    <div class="grid-12 gap-5">
      <div class="col-span-12 md:col-span-5 md:col-start-7">
        <p class="t-body-lg reveal-lines" data-split="lines" data-scroll data-scroll-offset="20%">
          No list, no tickets. You hear about it, you pull up, you keep it off your feed.
          The lot drops two hours before.
        </p>
      </div>
    </div>

    <!-- the three plates that stood here were cut; the gap they left is kept on purpose —
         one empty 3:2 cell of the same grid, so the distance to Rules stays exactly as it was -->
    <div class="three-images mt-8" aria-hidden="true"><div class="aspect-3-2"></div></div>
  </section>

  <!-- ═══ RULES · the reference's timeline (16-column grid) with the year rail
       replaced by a speedometer: a sticky full-screen arc from the bottom-left corner over the top
       to the bottom-right, an accent needle from the bottom centre that sweeps 0 → 200 as the
       section scrolls, the arc filling behind it. Six rules on the plates. ═══ -->
  <section id="rules" class="px-global section--deep">
    <div class="timeline" data-timeline-root>
      <!-- sticky for the section's whole height, takes no flow space;
           viewBox / arc / needle geometry set by JS to the viewport -->
      <div class="timeline__speedo" aria-hidden="true">
        <svg data-speedo preserveAspectRatio="none">
          <path class="timeline__rail" data-speedo-rail pathLength="1"/>
          <path class="timeline__progress" data-speedo-progress pathLength="1"/>
          <polygon class="timeline__needle" data-speedo-needle/>
        </svg>
        <span class="timeline__dial timeline__dial--0 t-year">0</span>
        <span class="timeline__dial timeline__dial--max t-year">200</span>
      </div>
      <!-- body shares the grid cell with the speedo layer, so the sticky layer adds no height
           and stops at the section's end like any sticky child -->
      <div class="timeline__body">
      <div class="pt-6 pb-43">
        <h2 class="t-display reveal-words" data-split="words" data-split-mask="words" data-scroll data-scroll-offset="30%">Rules</h2>
      </div>
      <div class="timeline__grid grid-16">
        <!-- row 1 · left, span 3 from col 2 -->
        <div class="timeline__cell" style="--row:1; --col: 2 / span 3">
          <div class="timeline__item" data-timeline-item>
            <div class="flex w-full items-center justify-between">
              <div class="timeline__media" data-timeline-item-child style="aspect-ratio: 4 / 3">
                <div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/52e76319-4056-49ce-84db-05836bd735b5.jpg" width="1200" height="896" alt="A palm thrust at the lens, blown out by the flash, a crowd and a car behind" loading="lazy" decoding="async" data-plate-fx></div></div>
              </div>
            </div>
            <div class="timeline__title timeline__title--right"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">01</span>No posting. Not the lot, not the plates, not the people.</div></h3></div>
          </div>
        </div>
        <!-- row 2 · right, span 6 from col 10 -->
        <div class="timeline__cell" style="--row:2; --col: 10 / span 6">
          <div class="timeline__item" data-timeline-item>
            <div class="flex w-full items-center justify-between">
              <div class="timeline__media" data-timeline-item-child style="aspect-ratio: 3 / 2">
                <div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/f157067a-0206-4e81-910f-81fe28677dbf.jpg" width="1200" height="797" alt="An open trunk with a tow strap, a jerry can and a fire extinguisher, hands pulling the strap out" loading="lazy" decoding="async" data-plate-fx></div></div>
              </div>
            </div>
            <div class="timeline__title timeline__title--left"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">02</span>Your car, your problem. Tow strap, fluids, a fire bottle — bring them.</div></h3></div>
          </div>
        </div>
        <!-- row 3 · left, span 4 from col 4 -->
        <div class="timeline__cell" style="--row:3; --col: 4 / span 4">
          <div class="timeline__item" data-timeline-item>
            <div class="flex w-full items-center justify-between">
              <div class="timeline__media" data-timeline-item-child style="aspect-ratio: 1 / 1">
                <div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/237faead-d5c2-4e2e-af1c-52ee809a44cf.jpg" width="1200" height="1200" alt="A rear wheel spinning in tyre smoke lit by a street lamp" loading="lazy" decoding="async" data-plate-fx></div></div>
              </div>
            </div>
            <div class="timeline__title timeline__title--right"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">03</span>One warm-up lap, then you’re live. Nobody waits.</div></h3></div>
          </div>
        </div>
        <!-- row 4 · right edge, span 4 from col 12 (edge → title pushed −40%) -->
        <div class="timeline__cell" style="--row:4; --col: 12 / span 4">
          <div class="timeline__item" data-timeline-item>
            <div class="flex w-full items-center justify-between">
              <div class="timeline__media" data-timeline-item-child style="aspect-ratio: 4 / 5">
                <div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/c78d5b93-1f6e-4663-bb0d-f348012ea11a.jpg" width="960" height="1200" alt="An empty lot with tyre circles and a knocked-over cone, tail lights leaving through the gate" loading="lazy" decoding="async" data-plate-fx></div></div>
              </div>
            </div>
            <div class="timeline__title timeline__title--left"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">04</span>The lot is ours until it isn’t. When the word comes, everyone is gone in five.</div></h3></div>
          </div>
        </div>
        <!-- row 5 · left, span 5 from col 1 -->
        <div class="timeline__cell" style="--row:5; --col: 1 / span 5">
          <div class="timeline__item" data-timeline-item>
            <div class="flex w-full items-center justify-between">
              <div class="timeline__media" data-timeline-item-child style="aspect-ratio: 16 / 9">
                <div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/8e275529-5405-4f1e-abf3-fd3cbcd17bf7.jpg" width="1200" height="675" alt="A row of traffic cones in the foreground, a car sliding past behind them, a crowd beyond" loading="lazy" decoding="async" data-plate-fx></div></div>
              </div>
            </div>
            <div class="timeline__title timeline__title--right"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">05</span>Nobody inside the line. Behind the cones or in a car.</div></h3></div>
          </div>
        </div>
        <!-- row 6 · right, span 3 from col 10 -->
        <div class="timeline__cell" style="--row:6; --col: 10 / span 3">
          <div class="timeline__item" data-timeline-item>
            <div class="flex w-full items-center justify-between">
              <div class="timeline__media" data-timeline-item-child style="aspect-ratio: 3 / 4">
                <div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/68cb177d-6ffc-4e19-a5d6-e54138188c86.jpg" width="900" height="1200" alt="Five guys in hoodies at a fence with their backs to the camera, one looking back" loading="lazy" decoding="async" data-plate-fx></div></div>
              </div>
            </div>
            <div class="timeline__title timeline__title--left"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">06</span>Show up once, you’re a face. Show up twice, you’re on the list. There is no list.</div></h3></div>
          </div>
        </div>
      </div>
      </div>
    </div>
  </section>

  <!-- ═══ NIGHTS · a two-line display statement, then the stacked
       slides, pinned + snapped on desktop; one photo per past night ═══ -->
  <section id="nights">
    <div class="px-global pt-6">
      <h2 class="t-display-tight reveal-words mb-display" data-split="words" data-split-mask="words" data-scroll data-scroll-offset="30%">
        Fourteen nights. What stayed off the feed.
      </h2>
    </div>
    <div class="stack" data-stack>
      <div class="stack__viewport" data-stack-viewport>
        <!-- reversed in the DOM: last slide first, so no z-index is needed; the dark layer sits on every slide that arrives over another (all but the first shown, [07]).
             Three nights, three different pictures on purpose: tail lights + city (cold), house light (warm), flash (hard) -->
        <div class="stack__slide">
          <div class="stack__media" data-stack-media><div class="stack__media-inner" data-stack-media-inner><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/6a65b344-e2dc-43a1-abf7-135a7df0eb77.jpg" width="2048" height="1360" alt="A black Silvia nose-down in the ditch under a guardrail, flash-lit; one guy with a foot on the rail, another on his phone" loading="lazy" decoding="async" style="object-position: 42% 50%"></div></div>
          <div class="stack__caption t-body-lg" data-stack-caption><div><p>[14] The hairpin</p><p>S15, hairpin nine, one foot on the guardrail. He walked back up the hill, got a lift, and was there again the week after.</p></div></div>
          <div class="stack__layer" data-stack-layer></div>
        </div>
        <div class="stack__slide">
          <div class="stack__media" data-stack-media><div class="stack__media-inner" data-stack-media-inner><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/522cfa99-d1b8-48ef-b37d-4bfa2644dad3.jpg" width="2048" height="1360" alt="A white R34 driven through the porch of a small house at night, a crowd on the lawn with phones up, one guy on the roof pointing" loading="lazy" decoding="async" style="object-position: 55% 50%"></div></div>
          <div class="stack__caption t-body-lg" data-stack-caption><div><p>[11] The house</p><p>The white R34 ended the night in a living room on a street we won’t name. Nobody remembers agreeing to it. Everyone has a photo, nobody posted one.</p></div></div>
          <div class="stack__layer" data-stack-layer></div>
        </div>
        <div class="stack__slide">
          <div class="stack__media" data-stack-media><div class="stack__media-inner" data-stack-media-inner><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/f4392b13-002f-4236-9308-d95eb8ec831f.jpg" width="2016" height="1344" alt="A girl sitting on the roof of a black R35 at a mountain-pass turnout, tail lights on, the city below" loading="lazy" decoding="async" style="object-position: 48% 50%"></div></div>
          <div class="stack__caption t-body-lg" data-stack-caption><div><p>[07] The pass</p><p>Someone’s sister showed up in a stock R35, sat on the roof through the whole second heat and drove home before anyone got her name.</p></div></div>
        </div>
      </div>
    </div>
  </section>

</main>

<!-- ═══ FOOTER · "Pull up" lands here. Follows the stack after one 6rem gap;
     the page recolours to the accent while this straddles the centre;
     the wordmark is filled with a picture that parallaxes inside the glyphs ═══ -->
<footer id="pull-up" class="h-screen px-global flex flex-col justify-between pt-6 pb-6 mt-24" data-footer-spy>
  <p class="t-quote reveal-lines md:max-w-66" data-split="lines" data-scroll data-scroll-offset="20%">
    No list, no address, no posts. Someone who was there tells you where. Pull up.
  </p>
  <div>
    <div class="flex items-end justify-between t-credit mb-5">
      <p>Undr. — a drift night that exists for a few hours. Keep it off your feed ©<span data-year-now>2026</span></p>
      <p class="md-only"><a class="link-dim" href="#rules">Rules</a>   <a class="link-dim" href="#">Ask for the drop</a></p>
    </div>
    <!-- wordmark: an HTML block clipped to the glyphs (clip-path → the SVG <text> below) holding the B&W
         still + its diamond raster (plate-fx); the frame is twice the block's height and parallaxes
         −50% → 0 on scroll. "Undr. Drift" at its natural width: font-size 362, tracking
         −14, ink 1405 of 1408, x −23 = the U's left bearing on the gutter; objectBoundingBox scale = 1/1408, 1/285 -->
    <div class="wordmark" data-wordmark aria-hidden="true">
      <div class="wordmark__frame" data-wordmark-frame>
        <img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/f4392b13-002f-4236-9308-d95eb8ec831f.jpg" width="2016" height="1344" alt="" loading="lazy" decoding="async" data-plate-fx>
      </div>
      <svg width="0" height="0" aria-hidden="true" focusable="false">
        <clipPath id="wordmark-clip" clipPathUnits="objectBoundingBox">
          <text transform="scale(0.000710227 0.00350877)" x="-23" y="279" font-family="Inter Tight, Inter Tight Fallback, sans-serif" font-weight="400" font-size="362" letter-spacing="-14">Undr. Drift</text>
        </clipPath>
      </svg>
    </div>
  </div>
</footer>

<!-- ═══ CTA pill — "Pull up": slides in with the first pixels of
     scroll, hides while the footer is on screen; glides to #pull-up via Lenis ═══ -->
<div class="pill-nav" data-pill-nav data-hidden="true">
  <div class="pill-nav__box">
    <a class="pill-nav__cta t-ui text-trim" href="#pull-up">Pull up ↗</a>
  </div>
</div>

<!-- ═══ page transition chrome ═══ -->
<div class="transition-scrim" data-transition-scrim></div>
<div class="transition-panel" data-transition-panel><img alt="" width="2880" height="1800"></div>

<script type="module">
/* ═══════════════════════════════════════════════════════════════════════════
   Undr. Drift — every module of the original lib/ folder plus main.js,
   concatenated in dependency order and stripped of import/export:

     lib/gsap.js · lib/scroll.js · lib/split.js · lib/reveal.js ·
     lib/raster.js · lib/hero-video.js · lib/hero-fx.js · lib/plate-fx.js ·
     lib/hero.js · lib/menu.js · lib/timeline.js · lib/stack.js ·
     lib/title-mask.js · lib/transition.js · lib/pill-nav.js · main.js
   ═══════════════════════════════════════════════════════════════════════════ */

/* ─── lib/gsap.js ──────────────────────────────────────────────────────────
   GSAP + plugins from the CDN globals (loaded with <script defer> above),
   registered once. The four named eases are the CSS eases so the JS and CSS
   halves of one motion share a curve. */
const { gsap, ScrollTrigger, SplitText, Flip, CustomEase } = window;

if (!gsap) throw new Error('gsap missing — check the CDN <script> order in the head');
gsap.registerPlugin(ScrollTrigger, SplitText, Flip, CustomEase);

CustomEase.create('magic',   '0.33, 0, 0, 1');      // --ease-magic
CustomEase.create('quart',   '0.41, 0.35, 0.2, 1'); // --ease-quart
CustomEase.create('outExpo', '0.19, 1, 0.22, 1');   // --ease-out-expo
CustomEase.create('base',    '0.46, 0.03, 0.12, 1');// --ease-base

const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = () => matchMedia('(max-width: 767px)').matches;


/* ─── lib/scroll.js ────────────────────────────────────────────────────────
   Smooth scroll + the class contract.
   Locomotive Scroll 5 (bundled, Lenis 1.3.17 inside) owns the scroll:
   - Lenis defaults (lerp .1, smoothWheel);
   - the ticker is gsap's, so Lenis, ScrollTrigger and every tween step in
     one frame; ScrollTrigger.update rides the lenis scroll event;
   - [data-scroll] elements get `.is-inview` from Locomotive's IntersectionObserver
     honouring data-scroll-offset / -repeat / -speed / -css-progress. */
let instance = null;
const listeners = new Set();

function initScroll() {
  if (instance) return instance;
  instance = new window.LocomotiveScroll({
    lenisOptions: {},
    initCustomTicker: (render) => gsap.ticker.add(render),
    destroyCustomTicker: (render) => gsap.ticker.remove(render),
    scrollCallback: (e) => listeners.forEach((fn) => fn(e)),
  });
  if (history.scrollRestoration) {
    history.scrollRestoration = 'manual';
    window.scrollTo(0, 0);
  }
  instance.lenisInstance?.on('scroll', ScrollTrigger.update);
  gsap.ticker.lagSmoothing(0);
  bindAnchors();
  return instance;
}

const getScroll = () => instance;
const onScroll = (fn) => { listeners.add(fn); return () => listeners.delete(fn); };

/* in-page anchors glide for 2s through Lenis instead of jumping */
function bindAnchors() {
  const isSamePage = (href) => {
    if (href.startsWith('#')) return true;
    const u = new URL(href, location.origin);
    return u.pathname === location.pathname && u.hash.length > 0;
  };
  document.addEventListener('click', (e) => {
    const a = e.target.closest('a');
    if (!a) return;
    const href = a.getAttribute('href');
    if (!href || !href.includes('#')) return;
    const hash = href.slice(href.indexOf('#'));
    if (hash && hash !== '#' && isSamePage(href) && document.querySelector(hash)) {
      e.preventDefault();
      instance?.scrollTo(hash, { duration: 2 });
    }
  });
  if (location.hash) {
    const hash = location.hash;
    requestAnimationFrame(() => instance?.scrollTo(hash, { duration: 2 }));
  }
}


/* ─── lib/split.js ─────────────────────────────────────────────────────────
   [data-split] → SplitText, after the font is in (a split measured against
   the fallback face re-flows). Every unit carries its index as a custom
   property (propIndex → --line / --word / --char, 1-based) — that index IS the
   stagger, computed in CSS (`transition-delay: calc(var(--word) * 75ms)`).
   `mask` wraps each unit in `.<unit>-mask`; the stylesheet swaps its
   overflow:clip for a clip-path with vertical bleed.
   Attributes: data-split="lines|words|chars" (default lines),
               data-split-mask="lines|words|chars" (default lines). */
let refreshQueued = false;
const queueRefresh = () => {
  if (refreshQueued) return;
  refreshQueued = true;
  requestAnimationFrame(() => { refreshQueued = false; ScrollTrigger.refresh(); });
};

function initSplit(root = document) {
  const splits = [];
  let cancelled = false;
  document.fonts.ready.then(() => requestAnimationFrame(() => {
    if (cancelled) return;
    gsap.utils.toArray('[data-split]', root).forEach((el) => {
      const { split, splitMask } = el.dataset;
      splits.push(new SplitText(el, {
        tag: 'span',
        type: split?.toLowerCase() || 'lines',
        mask: splitMask?.toLowerCase() || 'lines',
        charsClass: 'char',
        wordsClass: 'word',
        linesClass: 'line',
        propIndex: true,
        autoSplit: true,
        onSplit: queueRefresh,
      }));
    });
  }));
  return () => { cancelled = true; splits.forEach((s) => s.revert()); };
}


/* ─── lib/reveal.js ────────────────────────────────────────────────────────
   The non-split halves of the reveal contract + the footer spy.
   - .reveal-image gets `.is-loaded` once its <img> has pixels; CSS then plays
     reveal-clip only when BOTH .is-inview and .is-loaded are present;
   - footer spy: body.is-footer-inview while the footer straddles the
     viewport centre → the page recolours through the token --bg-footer. */
function initImageLoad(root = document) {
  root.querySelectorAll('.reveal-image:not([data-load-init])').forEach((box) => {
    box.setAttribute('data-load-init', '');
    const img = box.querySelector('img');
    const loaded = () => box.classList.add('is-loaded');
    if (!img) return loaded();
    if (img.complete && img.naturalWidth > 0) loaded();
    else {
      img.addEventListener('load', loaded, { once: true });
      img.addEventListener('error', loaded, { once: true });
    }
  });
}

function initFooterSpy(footer = document.querySelector('[data-footer-spy]')) {
  if (!footer) return;
  ScrollTrigger.create({
    trigger: footer,
    start: 'top center',
    end: 'bottom top',
    onEnter: () => document.body.classList.add('is-footer-inview'),
    onLeaveBack: () => document.body.classList.remove('is-footer-inview'),
  });
}

/* dev: replay a reveal — strip the state classes, force a reflow, put them back */
function replay(el) {
  const had = ['is-inview', 'is-loaded'].filter((c) => el.classList.contains(c));
  el.classList.remove('is-inview');
  el.querySelectorAll('.is-inview').forEach((n) => n.classList.remove('is-inview'));
  void el.offsetWidth;
  gsap.delayedCall(0.05, () => {
    had.forEach((c) => el.classList.add(c));
    el.querySelectorAll('[data-scroll]').forEach((n) => n.classList.add('is-inview'));
  });
}


/* ─── lib/raster.js ────────────────────────────────────────────────────────
   Diamond raster — the "Custom ASCII art" preset (renderMode "diamond")
   re-implemented as one WebGL fragment shader, as an engine any element can
   wear: the hero clip (with a trailing source and inertia) and the Rules
   plates (a still each).

   Passes (all GLSL ES 1.00, works on webgl1 and webgl2 contexts):
     1. down   source → cell texture (cols × rows): cover-mapped like the
               element under it, 3×3 taps averaged per cell, input desaturated
     2. blurH/V  bright cells (luma > .35) blurred at cell resolution → bloom
     3. main   per pixel: the cell's luma (+ edge emphasis, + flicker) sets the
               diamond radius |x|+|y| ≤ r inside the cell; three channels sample
               the field at ±chromatic offset; solid bg at bgOpacity, diamonds
               added on top ("lighter"); bloom; brightness/contrast/saturation/
               grayscale, tint; scan lines, vignette, film grain; glitch bands.

   createRaster({ host, params, className }) appends a <canvas class="raster">
   to `host` (absolute, inset 0) and returns { canvas, gl, params, setSource(el),
   upload(), markDirty(), render(time), resize(), set(partial), frameOk, lost,
   destroy() } — or null when WebGL is unavailable. The caller owns the loop,
   the gating and the timing; one WebGL context per raster. */

/* the preset, keys as in the source JSON (0–100 where the tool uses 0–100) */
const PRESET = {
  renderMode: 'diamond',
  bgMode: 'solid', bgColor: '#0d0e10', bgOpacity: 73,
  cellSize: 5, coverage: 100, invert: false, styleBlend: 'lighter',
  brightness: 100, contrast: 100, saturation: 100, grayscale: 0,
  edgeEmphasis: 43, density: 0,
  tint: '#d8b46a', tintOpacity: 0, overlayBlend: 'multiply',
  pfx: { vignette: 38, scanLines: 28, chromatic: 40, bloom: 60, filmGrain: 40, glitch: 20 },
  animated: true, animStyle: 'flicker', animSpeed: 180, animIntensity: 28,
  /* ours, not in the preset: the source video is greyscale under the effect */
  inputSaturation: 0,
  maxDpr: 1.5,
};

const VERT = `attribute vec2 a; varying vec2 v; void main(){ v = a * .5 + .5; gl_Position = vec4(a, 0., 1.); }`;

const FRAG_DOWN = `precision mediump float;
uniform sampler2D u_video; uniform vec2 u_grid, u_cellUV, u_cover; uniform float u_inSat;
varying vec2 v;
float luma(vec3 c){ return dot(c, vec3(.2126, .7152, .0722)); }
void main(){
  vec3 acc = vec3(0.);
  for (int i = -1; i <= 1; i++) for (int j = -1; j <= 1; j++) {
    vec2 uv = (v * u_grid + vec2(float(i), float(j)) / 3.) * u_cellUV;   /* cell centre ± a third of a cell, in canvas uv */
    acc += texture2D(u_video, (uv - .5) * u_cover + .5).rgb;             /* object-fit: cover, like the <video> */
  }
  acc /= 9.;
  gl_FragColor = vec4(mix(vec3(luma(acc)), acc, u_inSat), 1.);
}`;

const FRAG_BLUR = `precision mediump float;
uniform sampler2D u_tex; uniform vec2 u_dir; uniform float u_thresh;
varying vec2 v;
float luma(vec3 c){ return dot(c, vec3(.2126, .7152, .0722)); }
vec3 tap(vec2 uv){ vec3 c = texture2D(u_tex, uv).rgb; return u_thresh > 0. ? c * smoothstep(u_thresh, 1., luma(c)) : c; }
void main(){
  vec3 c = tap(v) * .227;
  c += (tap(v + u_dir) + tap(v - u_dir)) * .195;
  c += (tap(v + u_dir * 2.) + tap(v - u_dir * 2.)) * .122;
  c += (tap(v + u_dir * 3.) + tap(v - u_dir * 3.)) * .054;
  c += (tap(v + u_dir * 4.) + tap(v - u_dir * 4.)) * .016;
  gl_FragColor = vec4(c, 1.);
}`;

const FRAG_MAIN = `precision highp float;
uniform sampler2D u_cells, u_bloom;
uniform vec2 u_res, u_grid;
uniform float u_scale, u_cell, u_time;
uniform vec3 u_bg, u_tint;
uniform float u_bgA, u_edge, u_flickSpeed, u_flickAmp, u_coverage, u_invert;
uniform float u_bright, u_contrast, u_sat, u_gray, u_tintA;
uniform float u_vig, u_scan, u_chroma, u_bloomI, u_grain, u_glitch;
varying vec2 v;
float hash(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float luma(vec3 c){ return dot(c, vec3(.2126, .7152, .0722)); }
/* p in CSS px → (diamond colour, coverage) of the cell under p */
vec4 field(vec2 p){
  vec2 cell = floor(p / u_cell);
  vec2 cuv = (cell + .5) / u_grid;
  vec3 c = texture2D(u_cells, cuv).rgb;
  float l = luma(c);
  float lx = luma(texture2D(u_cells, cuv - vec2(1. / u_grid.x, 0.)).rgb);
  float ly = luma(texture2D(u_cells, cuv - vec2(0., 1. / u_grid.y)).rgb);
  l = clamp(l + (abs(l - lx) + abs(l - ly)) * u_edge * 2., 0., 1.);          /* edgeEmphasis */
  float f = hash(cell + floor(u_time * u_flickSpeed) * .37);                 /* flicker: per cell, re-rolled flickSpeed×/s */
  l = clamp(l * (1. + (f - .5) * 2. * u_flickAmp), 0., 1.);
  if (u_invert > .5) l = 1. - l;
  float keep = step(hash(cell * 1.7 + 3.1), u_coverage);                    /* coverage */
  vec2 local = fract(p / u_cell) - .5;
  float d = abs(local.x) + abs(local.y);                                     /* diamond SDF, cell units */
  float aa = .7 / (u_cell * u_scale);
  float cov = (1. - smoothstep(l * .5 - aa, l * .5 + aa, d)) * keep;
  vec3 col = c * (l / max(luma(c), 1e-4));                                   /* the cell colour at the emphasised luma */
  return vec4(col, cov);
}
void main(){
  vec2 p = gl_FragCoord.xy / u_scale;
  vec2 cssRes = u_res / u_scale;
  vec2 uv = gl_FragCoord.xy / u_res;
  vec2 dir = uv - .5;
  float dist = length(dir * vec2(cssRes.x / cssRes.y, 1.));
  /* glitch: a few horizontal bands slip sideways for a moment */
  float seed = floor(u_time * 9.);
  float bandH = 12. + 40. * hash(vec2(seed, 1.7));
  float band = floor(p.y / bandH);
  float on = step(1. - u_glitch * .45, hash(vec2(seed, band)));
  p.x += on * (hash(vec2(band, seed)) - .5) * 60. * u_glitch;
  /* chromatic aberration grows with the distance from the centre */
  vec2 off = dir * dist * u_chroma * 22. * (1. + on * 3.);                 /* ≈1.5 cells at the edge for chromatic 40 */
  vec4 fr = field(p + off), fg = field(p), fb = field(p - off);
  float cov = (fr.a + fg.a + fb.a) / 3.;
  vec3 dia = vec3(fr.r * fr.a, fg.g * fg.a, fb.b * fb.a);
  /* bgMode solid at bgOpacity, then the diamonds added ("lighter"), premultiplied */
  vec3 rgb = u_bg * u_bgA + dia;
  float alpha = u_bgA + (1. - u_bgA) * cov;
  vec3 bl = texture2D(u_bloom, p / (u_cell * u_grid)).rgb;
  rgb += bl * u_bloomI * .8;
  alpha = clamp(alpha + luma(bl) * u_bloomI * .5, 0., 1.);
  /* colour adjustments in the recipe's order, on straight colour */
  vec3 c = rgb / max(alpha, 1e-4);
  c *= u_bright;
  c = (c - .5) * u_contrast + .5;
  c = mix(vec3(luma(c)), c, u_sat);
  c = mix(c, vec3(luma(c)), u_gray);
  c = mix(c, c * u_tint, u_tintA);
  /* post: scan lines (2 css px), vignette, film grain */
  c *= 1. - u_scan * .8 * (.5 + .5 * sin(p.y * 3.14159265));
  c *= 1. - u_vig * smoothstep(.5, 1.1, dist);
  c += (hash(gl_FragCoord.xy * .37 + vec2(u_time * 13., u_time * 7.)) - .5) * u_grain * .35;
  c = clamp(c, 0., 1.);
  gl_FragColor = vec4(c * alpha, alpha);
}`;

const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);

function createRaster({ host, params = {}, className = 'raster' } = {}) {
  if (!host || reduced) return null;
  const P = { ...PRESET, ...params, pfx: { ...PRESET.pfx, ...(params.pfx || {}) } };
  const canvas = document.createElement('canvas');
  canvas.className = className;
  canvas.setAttribute('aria-hidden', 'true');
  host.appendChild(canvas);
  const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, powerPreference: 'high-performance' });
  if (!gl) { canvas.remove(); return null; }

  /* ── programs ── */
  const compile = (type, src) => {
    const sh = gl.createShader(type);
    gl.shaderSource(sh, src); gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error('raster shader: ' + gl.getShaderInfoLog(sh));
    return sh;
  };
  const program = (frag) => {
    const pr = gl.createProgram();
    gl.attachShader(pr, compile(gl.VERTEX_SHADER, VERT));
    gl.attachShader(pr, compile(gl.FRAGMENT_SHADER, frag));
    gl.linkProgram(pr);
    if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) throw new Error('raster link: ' + gl.getProgramInfoLog(pr));
    const u = {};
    const n = gl.getProgramParameter(pr, gl.ACTIVE_UNIFORMS);
    for (let i = 0; i < n; i++) { const info = gl.getActiveUniform(pr, i); u[info.name] = gl.getUniformLocation(pr, info.name); }
    return { pr, u };
  };
  let prDown, prBlur, prMain;
  try { prDown = program(FRAG_DOWN); prBlur = program(FRAG_BLUR); prMain = program(FRAG_MAIN); }
  catch (e) { console.warn(e.message); canvas.remove(); return null; }

  /* one big triangle */
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  for (const { pr } of [prDown, prBlur, prMain]) {
    const a = gl.getAttribLocation(pr, 'a');
    gl.enableVertexAttribArray(a);
    gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);
  }

  /* ── textures / framebuffers ── */
  const texture = (filter) => {
    const t = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, t);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    return t;
  };
  const target = (filter) => {
    const t = texture(filter), fb = gl.createFramebuffer();
    return { t, fb, w: 0, h: 0, size(w, h) {
      if (this.w === w && this.h === h) return;
      this.w = w; this.h = h;
      gl.bindTexture(gl.TEXTURE_2D, t);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
      gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
    } };
  };
  const srcTex = texture(gl.LINEAR);
  const cells = target(gl.NEAREST), tmp = target(gl.LINEAR), bloom = target(gl.LINEAR);

  /* ── size ── */
  let dirty = true, frameOk = false, lost = false;   /* dirty: the source has a new frame waiting to be uploaded */
  const size = { w: 0, h: 0, cssW: 0, cssH: 0, scale: 1, cols: 1, rows: 1 };
  const resize = () => {
    const r = host.getBoundingClientRect();
    /* layout size, not the transformed one — hosts get scaled by intros */
    const cssW = host.clientWidth || r.width, cssH = host.clientHeight || r.height;
    if (!cssW || !cssH) return;
    const scale = Math.min(devicePixelRatio || 1, P.maxDpr);
    Object.assign(size, { cssW, cssH, scale, w: Math.round(cssW * scale), h: Math.round(cssH * scale), cols: Math.ceil(cssW / P.cellSize), rows: Math.ceil(cssH / P.cellSize) });
    canvas.width = size.w; canvas.height = size.h;
    cells.size(size.cols, size.rows); tmp.size(size.cols, size.rows); bloom.size(size.cols, size.rows);
    dirty = true;
  };
  const ro = new ResizeObserver(resize);
  ro.observe(host);
  resize();

  /* ── source ── */
  let source = null;
  const hasPixels = (el) => el && (
    el instanceof HTMLVideoElement ? (frameOk || el.readyState >= 2)   /* after the first frame the presented frame is always valid, seeking or not */
    : el instanceof HTMLImageElement ? (el.complete && el.naturalWidth > 0)
    : true);
  const srcSize = (el) => el instanceof HTMLVideoElement ? [el.videoWidth || 16, el.videoHeight || 9]
    : el instanceof HTMLImageElement ? [el.naturalWidth || 3, el.naturalHeight || 2]
    : [el.width || 1, el.height || 1];
  const upload = () => {
    if (lost || !hasPixels(source)) return false;
    gl.bindTexture(gl.TEXTURE_2D, srcTex);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
    let ok = false;
    try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); frameOk = true; ok = true; }
    catch { /* not decodable yet — keep the last frame */ }
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
    dirty = false;
    return ok;
  };

  /* ── render ── */
  const draw = (pr, fb, w, h) => {
    gl.useProgram(pr.pr);
    gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
    gl.viewport(0, 0, w, h);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
  };
  const bind = (unit, t) => { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t); };
  const render = (time = 0) => {
    if (lost || !size.w || !source) return false;
    if (dirty) upload();
    if (!frameOk) return false;
    const [sw, sh] = srcSize(source);
    const ca = size.cssW / size.cssH, sa = sw / sh;
    const cover = sa > ca ? [ca / sa, 1] : [1, sa / ca];
    /* 1 · cells */
    gl.useProgram(prDown.pr);
    bind(0, srcTex);
    gl.uniform1i(prDown.u.u_video, 0);
    gl.uniform2f(prDown.u.u_grid, size.cols, size.rows);
    gl.uniform2f(prDown.u.u_cellUV, P.cellSize / size.cssW, P.cellSize / size.cssH);
    gl.uniform2f(prDown.u.u_cover, cover[0], cover[1]);
    gl.uniform1f(prDown.u.u_inSat, P.inputSaturation);
    draw(prDown, cells.fb, cells.w, cells.h);
    /* 2 · bloom: bright cells, blurred H then V at cell resolution */
    gl.useProgram(prBlur.pr);
    bind(0, cells.t);
    gl.uniform1i(prBlur.u.u_tex, 0);
    gl.uniform2f(prBlur.u.u_dir, 1 / size.cols, 0);
    gl.uniform1f(prBlur.u.u_thresh, 0.35);
    draw(prBlur, tmp.fb, tmp.w, tmp.h);
    bind(0, tmp.t);
    gl.uniform2f(prBlur.u.u_dir, 0, 1 / size.rows);
    gl.uniform1f(prBlur.u.u_thresh, 0);
    draw(prBlur, bloom.fb, bloom.w, bloom.h);
    /* 3 · main */
    const u = prMain.u, pf = P.pfx;
    gl.useProgram(prMain.pr);
    bind(0, cells.t); bind(1, bloom.t);
    gl.uniform1i(u.u_cells, 0); gl.uniform1i(u.u_bloom, 1);
    gl.uniform2f(u.u_res, size.w, size.h);
    gl.uniform2f(u.u_grid, size.cols, size.rows);
    gl.uniform1f(u.u_scale, size.scale);
    gl.uniform1f(u.u_cell, P.cellSize);
    gl.uniform1f(u.u_time, P.animated ? time : 0);
    gl.uniform3fv(u.u_bg, hex(P.bgColor));
    gl.uniform1f(u.u_bgA, P.bgMode === 'none' ? 0 : P.bgOpacity / 100);
    gl.uniform1f(u.u_edge, P.edgeEmphasis / 100);
    gl.uniform1f(u.u_flickSpeed, P.animated && P.animStyle === 'flicker' ? P.animSpeed / 100 * 6 : 0);
    gl.uniform1f(u.u_flickAmp, P.animated ? P.animIntensity / 100 : 0);
    gl.uniform1f(u.u_coverage, P.coverage / 100);
    gl.uniform1f(u.u_invert, P.invert ? 1 : 0);
    gl.uniform1f(u.u_bright, P.brightness / 100);
    gl.uniform1f(u.u_contrast, P.contrast / 100);
    gl.uniform1f(u.u_sat, P.saturation / 100);
    gl.uniform1f(u.u_gray, P.grayscale / 100);
    gl.uniform3fv(u.u_tint, hex(P.tint));
    gl.uniform1f(u.u_tintA, P.tintOpacity / 100);
    gl.uniform1f(u.u_vig, pf.vignette / 100);
    gl.uniform1f(u.u_scan, pf.scanLines / 100);
    gl.uniform1f(u.u_chroma, pf.chromatic / 100);
    gl.uniform1f(u.u_bloomI, pf.bloom / 100);
    gl.uniform1f(u.u_grain, pf.filmGrain / 100);
    gl.uniform1f(u.u_glitch, pf.glitch / 100);
    draw(prMain, null, size.w, size.h);
    return true;
  };

  canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); lost = true; canvas.hidden = true; });

  return {
    canvas, gl,
    get params() { return P; },
    get frameOk() { return frameOk; },
    get lost() { return lost; },
    get source() { return source; },
    setSource(el) { source = el; frameOk = false; dirty = true; },
    markDirty() { dirty = true; },
    upload, render, resize,
    set(partial = {}) { Object.assign(P, partial, { pfx: { ...P.pfx, ...(partial.pfx || {}) } }); resize(); },
    destroy() { ro.disconnect(); canvas.remove(); gl.getExtension('WEBGL_lose_context')?.loseContext(); },
  };
}


/* ─── lib/hero-video.js ────────────────────────────────────────────────────
   Scroll-scrubbed hero video ("seek-coalescing scrubber"). The clip is
   all-intra (`ffmpeg -g 1`), so a plain `currentTime` seek lands on any frame
   in ~10ms and no frame bank is needed.
   - at most ONE seek in flight, always chasing the latest target (`seeked` → next);
   - 30ms dead zone (~¾ of a 24fps frame) so a settled scroll stops seeking;
   - poster-pin at rest: `loadedmetadata` → pause + currentTime .001;
   - warm the decoder once on `canplay` (seek to ~3% and back);
   - `ready` resolves when metadata + first frame are in (or on error / 4s
     timeout, so a missing clip never blocks the hero intro);
   - `onFrame(fn)`: fn runs inside `seeked`, BEFORE the next seek goes out.
     While a scrub is running the element sits in `seeking` with readyState 1
     the whole time, so anyone who needs the frame's pixels (the WebGL raster)
     must take them here.
   Reduced motion: no scrubbing, the poster/first frame stays. */
function initHeroVideo(video = document.querySelector('[data-hero-video]')) {
  if (!video) return { scrub() {}, ready: Promise.resolve(), video: null };

  let pending = null;
  let seeking = false;
  const frameListeners = new Set();
  const applyNext = () => {
    /* a frame has landed: consumers take it now, then the next seek goes out */
    frameListeners.forEach((fn) => fn(video));
    if (pending === null) { seeking = false; return; }
    const t = pending; pending = null;
    if (Math.abs(video.currentTime - t) < 0.01) { seeking = false; return; }
    seeking = true;
    try { video.currentTime = t; } catch { seeking = false; }
  };
  video.addEventListener('seeked', applyNext);

  /* p01 ∈ [0,1] → clip time */
  const scrub = (p01) => {
    if (reduced) return;
    const d = video.duration;
    if (!d || video.readyState < 1) return;
    if (!video.paused) video.pause();
    const target = Math.min(d - 0.001, Math.max(0, p01 * d));
    if (!seeking && Math.abs(video.currentTime - target) < 0.03) return;
    pending = target;
    if (!seeking) applyNext();
  };

  const ready = new Promise((resolve) => {
    let done = false;
    const finish = () => { if (!done) { done = true; resolve(); } };
    const onMeta = () => { video.pause(); try { video.currentTime = 0.001; } catch {} };
    const onCanPlay = () => {
      /* prime the decoder: one seek forward and back, then we are ready */
      const warm = Math.min(0.12, (video.duration || 4) * 0.03);
      const back = () => { video.removeEventListener('seeked', back); try { video.currentTime = 0.001; } catch {} finish(); };
      video.addEventListener('seeked', back, { once: true });
      try { video.currentTime = warm; } catch { finish(); }
    };
    if (video.readyState >= 1) onMeta(); else video.addEventListener('loadedmetadata', onMeta, { once: true });
    if (video.readyState >= 3) onCanPlay(); else video.addEventListener('canplay', onCanPlay, { once: true });
    video.addEventListener('error', finish, { once: true });
    setTimeout(finish, 4000);
  });

  const onFrame = (fn) => { frameListeners.add(fn); return () => frameListeners.delete(fn); };
  return { scrub, ready, video, onFrame, get seeking() { return seeking; } };
}


/* ─── lib/hero-fx.js ───────────────────────────────────────────────────────
   Hero diamond raster — the raster engine worn by the hero clip.
   Frames: the texture is refilled inside a scrubber's `onFrame` hook — i.e.
   in `seeked`, before the next seek is issued. During a scrub the element is
   `seeking` (readyState 1) continuously, so a ticker-timed upload would wait
   for the scroll to stop and the raster would lag the clip by that much.
   Inertia: the raster has its OWN source — a 360p all-intra copy of the clip
   (`lagSrc`) scrubbed with the same seek-coalescing scrubber to a time that
   eases toward the clip's time every tick,
   `lagP += (targetP − lagP)·(1 − e^(−dt/lagTau))`. The clip moves first, the
   raster trails and decelerates into place. `lagTau 0` or no second source
   (mobile by default) → the raster reads the main <video> frame by frame.
   The hero root gets `.has-fx` (CSS desaturates the <video> that shows
   through at 27%). Renders on gsap.ticker while the stage is on screen and
   the scene is not faded (p < 1.5).
   Roll back: FX = false below (or `?fx=off` in the URL). */
const HERO_PARAMS = {
  /* inertia: the raster's own low-res source trails the clip, time constant in seconds (0 = frame-locked) */
  lagTau: 0.3,
  lagSrc: 'https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/5699b256-92c4-46d5-8778-bd48dc6de453.mp4',   /* CloudFront, CORS + ranges */
  lagOnMobile: false,
};

function initHeroFx({ video, host = document.querySelector('[data-scene]'), root = host?.closest('[data-hero]'), ready = Promise.resolve(), onFrame, params = {} } = {}) {
  if (!video || !host) return null;
  const H = { ...HERO_PARAMS, ...params };
  root?.classList.add('has-fx');                      /* the <video> under the canvas goes B&W (CSS) */
  const stub = { canvas: null, ready: Promise.resolve(), set() {}, pause() {}, resume() {}, progress() {}, get params() { return H; } };
  if (reduced) return stub;

  /* the raster's frame source: its own trailing copy of the clip, or the main <video> */
  const trailing = H.lagTau > 0 && !!H.lagSrc && (H.lagOnMobile || !isMobile());
  let source = video, sourceScrub = null, sourceState = null, frameHook = onFrame, fxReady = ready;
  if (trailing) {
    source = document.createElement('video');
    source.muted = true; source.playsInline = true; source.preload = 'auto';
    source.crossOrigin = 'anonymous';                /* remote clip → the texture upload needs CORS */
    source.setAttribute('aria-hidden', 'true');
    /* kept renderable (not display:none) so every browser keeps presenting frames to texImage2D */
    source.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none';
    source.src = H.lagSrc;
    host.appendChild(source);
    const s = initHeroVideo(source);
    sourceScrub = s.scrub; frameHook = s.onFrame; fxReady = s.ready; sourceState = s;
  }

  const raster = createRaster({ host, params, className: 'raster hero__fx' });
  if (!raster) { if (trailing) source.remove(); root?.classList.remove('has-fx'); return null; }
  raster.setSource(source);

  /* the scrub: take each landed frame inside `seeked`, before the scrubber's next seek */
  let lastFrame = -1, paused = false;
  const take = () => { if (!paused && raster.upload()) lastFrame = source.currentTime; };
  if (frameHook) frameHook(take);
  /* everything else (rest, warm-up, a frame presented for any other reason) just marks dirty for the ticker */
  const mark = () => raster.markDirty();
  source.addEventListener('seeked', mark);
  source.addEventListener('loadeddata', mark);
  if ('requestVideoFrameCallback' in source) {
    const tick = () => { raster.markDirty(); source.requestVideoFrameCallback(tick); };
    source.requestVideoFrameCallback(tick);
  }

  /* ── inertia: the trailing source eases toward the clip's time ── */
  let targetP = 0, lagP = 0, prog = 0, active = true;
  const trail = (dtMs) => {
    if (!sourceScrub || raster.lost) return;
    if (prog >= 1.5 || H.lagTau <= 0) { lagP = targetP; return; }          /* scene faded: no seeks, just stay in sync */
    const k = 1 - Math.exp(-(dtMs / 1000) / H.lagTau);
    lagP += (targetP - lagP) * k;
    if (Math.abs(targetP - lagP) < 0.0005) {
      lagP = targetP;
      /* arrived: land on the clip's exact frame — the scrubber's 30ms dead zone would leave the last frame behind */
      const d = source.duration, t = d ? Math.min(d - 0.001, targetP * d) : 0;
      if (d && !sourceState.seeking && Math.abs(source.currentTime - t) > 0.01) { try { source.currentTime = t; } catch {} }
      return;
    }
    sourceScrub(lagP);                                                       /* the scrubber's dead zone swallows the no-ops */
  };

  /* ── loop ── */
  const t0 = performance.now();
  const render = (_, dtMs) => {
    trail(dtMs || 16.7);
    if (paused || !active || prog >= 1.5) return;      /* a hidden tab stops rAF, so the ticker, on its own */
    if (raster.render((performance.now() - t0) / 1000) && lastFrame < 0) lastFrame = source.currentTime;
  };
  raster.canvas.addEventListener('webglcontextlost', () => root?.classList.remove('has-fx'));
  fxReady.then(() => { raster.markDirty(); gsap.ticker.add(render); });
  /* only while the stage is on screen */
  if (root) ScrollTrigger.create({ trigger: root, start: 'top bottom', end: 'bottom top', onToggle: (self) => { active = self.isActive; } });

  return {
    raster, canvas: raster.canvas, gl: raster.gl, source, ready: fxReady,
    get params() { return { ...raster.params, ...H }; },
    get lastFrame() { return lastFrame; },
    get lagP() { return lagP; },
    get targetP() { return targetP; },
    set(partial = {}) { for (const k of Object.keys(HERO_PARAMS)) if (k in partial) H[k] = partial[k]; raster.set(partial); },
    pause() { paused = true; },
    resume() { paused = false; },
    progress(p) { prog = p; targetP = Math.min(1, Math.max(0, p / 1.5)); },
    render: () => raster.render((performance.now() - t0) / 1000),
  };
}


/* ─── lib/plate-fx.js ──────────────────────────────────────────────────────
   Diamond raster on stills — the raster engine worn by the Rules plates.
   Markup: .timeline__frame[data-timeline-item-frame] > img[data-plate-fx]
   The canvas is appended beside the <img> inside the frame (the frame is the
   positioned box both fill; the reveal clip and the hover inset stay on the
   parents, the parallax moves the frame — image and raster move together).
   The image is uploaded once, after load + decode; the raster renders on
   gsap.ticker only while the plate is on screen, so six plates cost about one
   hero. The <img> shows through at 27% and is B&W in CSS. */
function initPlateFx(imgs = document.querySelectorAll('img[data-plate-fx]')) {
  const plates = [];
  imgs.forEach((img) => {
    const host = img.parentElement;
    const loaded = () => img.classList.add('is-loaded');
    if (img.complete && img.naturalWidth > 0) loaded();
    else { img.addEventListener('load', loaded, { once: true }); img.addEventListener('error', loaded, { once: true }); }
    if (reduced) return;

    const raster = createRaster({ host });
    if (!raster) return;
    raster.setSource(img);
    const plate = { raster, img, active: false };
    const take = () => (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(() => raster.upload());
    if (img.complete && img.naturalWidth > 0) take();
    else img.addEventListener('load', take, { once: true });

    const t0 = performance.now() - Math.random() * 10000;   /* unsynchronised flicker across plates */
    const render = () => { if (plate.active) raster.render((performance.now() - t0) / 1000); };
    gsap.ticker.add(render);
    ScrollTrigger.create({ trigger: host, start: 'top bottom', end: 'bottom top', onToggle: (self) => { plate.active = self.isActive; } });
    plates.push(plate);
  });
  return plates;
}


/* ─── lib/hero.js ──────────────────────────────────────────────────────────
   Hero stage choreography (intro + scrub-out), media-agnostic.

   Intro (page load, scroll locked until it ends):
     t=0   layers scale 1.4→1.2 (1s power4.out)    scene clip-path: a plus grows from nothing to icon
                                                   size at the centre (L 4.5vh, stroke .14·L) (1s power4.out)
     t=1   layers scale 1.2→1   (2s power4.inOut)  the plus keeps growing until its bars swallow the
                                                   frame (L 2.2·max(cx,cy), w 1.05·max) (2s power4.inOut), then clip none
           both words: chars from x:1em (2s power4.inOut, stagger .01, masked)
     <50%  rule .is-inview (scaleX 0→1, 1.4s out-expo in CSS)
     <     row words from xPercent 50 / autoAlpha 0 (.6s magic, stagger .15)
     <20%  date from x:−1em, time from x:+1em (.8s magic) · "+" from rotate 90 / scale 0 (.8s magic)
     <50%  note autoAlpha 0→1 (1s power4.inOut)
     -=.8  scroll unlocked
   Scrub (top top → +=55% — the --hero-runway token, progress still 0→1.5 for the
   media), the chrome leaves inside the first .6 (= 40% of the runway), the scene
   fades to the night plate over the last .45 (from p 1.05). */
let introDone = false;

/* 12-vertex "+" polygon around (cx, cy): L = half arm length, w = half stroke.
   The scene opens through the same plus that sits between the date and the time. */
const plusPath = (cx, cy, L, w) => `polygon(${[
  [cx - w, cy - L], [cx + w, cy - L], [cx + w, cy - w], [cx + L, cy - w],
  [cx + L, cy + w], [cx + w, cy + w], [cx + w, cy + L], [cx - w, cy + L],
  [cx - w, cy + w], [cx - L, cy + w], [cx - L, cy - w], [cx - w, cy - w],
].map(([x, y]) => `${x.toFixed(1)}px ${y.toFixed(1)}px`).join(', ')})`;

function initHero({ root = document.querySelector('[data-hero]'), onProgress, ready = Promise.resolve() } = {}) {
  if (!root) return null;
  const scroll = getScroll();
  const scene = root.querySelector('[data-scene-wrapper]');
  const layers = root.querySelector('[data-scene]');
  const rule = root.querySelector('[data-hero-rule]');

  /* lock scroll until the intro releases it; failsafe so a stalled media
     promise never leaves the page dead */
  requestAnimationFrame(() => { if (!introDone) scroll?.stop(); });
  const failsafe = setTimeout(release, 6000);

  function release() {
    if (introDone) return;
    introDone = true;
    clearTimeout(failsafe);
    scroll?.start();
  }

  let split, scrub;
  ready.then(() => {
    gsap.set(root, { visibility: 'visible' });
    split = SplitText.create('[data-line]', { tag: 'span', type: 'chars', mask: 'chars', charsClass: 'char', propIndex: true, autoSplit: true });

    if (reduced) {
      gsap.set([scene, layers], { clearProps: 'all' });
      rule?.classList.add('is-inview');
      release();
    } else {
      const cx = scene.clientWidth / 2, cy = scene.clientHeight / 2;
      const iconL = Math.max(28, innerHeight * 0.045), cover = Math.max(cx, cy);
      const plus = { L: 0, w: 0 };
      const applyPlus = () => gsap.set(scene, { clipPath: plusPath(cx, cy, plus.L, plus.w) });
      applyPlus();
      gsap.timeline()
        .fromTo(layers, { scale: 1.4 }, { scale: 1.2, duration: 1, ease: 'power4.out' }, 0)
        .to(layers, { scale: 1, duration: 2, ease: 'power4.inOut' }, 1)
        .to(plus, { L: iconL, w: iconL * 0.14, duration: 1, ease: 'power4.out', onUpdate: applyPlus }, 0)
        .from(split.chars, { duration: 2, x: '1em', ease: 'power4.inOut', stagger: { each: 0.01, from: 'start' } }, 1)
        .to(plus, { L: cover * 2.2, w: cover * 1.05, duration: 2, ease: 'power4.inOut', onUpdate: applyPlus }, '<')
        .call(() => rule?.classList.add('is-inview'), undefined, '<50%')
        .from('[data-hero-row] > *', { xPercent: 50, autoAlpha: 0, duration: 0.6, ease: 'magic', stagger: 0.15 }, '<')
        .from('[data-hero-date]', { x: '-1em', autoAlpha: 0, duration: 0.8, ease: 'magic' }, '<20%')
        .from('[data-hero-time]', { x: '1em', autoAlpha: 0, duration: 0.8, ease: 'magic' }, '<')
        .from('[data-hero-plus]', { rotate: 90, scale: 0, autoAlpha: 0, duration: 0.8, ease: 'magic' }, '<')
        .fromTo('[data-hero-note]', { autoAlpha: 0 }, { autoAlpha: 1, duration: 1, ease: 'power4.inOut' }, '<50%')
        .call(release, undefined, '-=0.8')
        .set(scene, { clipPath: 'none' }, 3);   /* the plus has swallowed the frame by t=3 — drop the clip */
    }

    /* scrub — the media hook gets the raw 0→1.5; the chrome is gone by OUT */
    const OUT = 0.6;
    const state = { progress: 0 };
    gsap.matchMedia().add('(prefers-reduced-motion: no-preference)', () => {
      scrub = gsap.timeline({
        defaults: { ease: 'none' },
        scrollTrigger: { trigger: root, start: 'top top', end: '+=55%', scrub: true },
      });
      scrub.to(state, { progress: 1.5, duration: 1.5, onUpdate: () => onProgress?.(state.progress) }, 0)
        .to('[data-line="1"]', { xPercent: -110, duration: OUT }, 0)
        .to('[data-line="2"]', { xPercent: 110, duration: OUT }, 0)
        .to('[data-hero-rule]', { scaleX: 0, transformOrigin: '0 50%', duration: OUT }, 0)
        .to('[data-hero-date]', { xPercent: -110, duration: OUT }, 0)
        .to('[data-hero-time]', { xPercent: 110, duration: OUT }, 0)
        .to('[data-hero-plus]', { rotate: 90, autoAlpha: 0, duration: OUT }, 0)
        .fromTo('[data-hero-row], [data-hero-note]', { autoAlpha: 1 }, { autoAlpha: 0, duration: OUT, immediateRender: false }, 0)
        /* the media fades into the night plate over the last 20% of the runway,
           so the next section arrives over black, not over a frozen frame */
        .to('[data-scene]', { autoAlpha: 0, duration: 0.45, ease: 'none' }, 1.05);
    });
  });

  return {
    revert() { scrub?.scrollTrigger?.kill(); scrub?.kill(); split?.revert(); },
  };
}


/* ─── lib/menu.js ──────────────────────────────────────────────────────────
   Full-screen menu. One fixed black panel; GSAP owns clip-path, CSS owns
   layout. One toggle — the fixed burger — opens and closes it (aria-expanded
   drives the bar → cross morph in CSS). Scroll stops while open; Escape closes.
   open : panel clip polygon(0 0,100% 0,100% 0,0 0) → full   1s power3.inOut
          nav li > *  y 80 → 0, autoAlpha 0 → 1              1s power3.out, stagger .15, at "<40%"
          .menu__footer y 40 → 0, autoAlpha                 .6s power3.inOut, at "<40%"
   close: nav li > *  y 0 → −80, autoAlpha → 0              .2s power2.inOut, stagger .05
          .menu__footer y → 80, autoAlpha → 0               .2s power2.inOut, "<"
          panel clip → top line                             .4s power2.out, at "<80%"; then autoAlpha 0 */
const OPEN = 'polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)';
const CLOSED = 'polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)';

function initMenu({ panel = document.querySelector('[data-menu]'), toggle = document.querySelector('[data-menu-toggle]') } = {}) {
  if (!panel) return null;
  let tl = null;
  let isOpen = false;
  const items = () => panel.querySelectorAll('nav li > *');
  const footer = panel.querySelector('.menu__footer');
  const setExpanded = (v) => toggle?.setAttribute('aria-expanded', v ? 'true' : 'false');

  function open() {
    if (isOpen) return;
    isOpen = true;
    setExpanded(true);
    getScroll()?.stop();
    panel.removeAttribute('inert');
    tl?.kill();
    tl = gsap.timeline({ overwrite: true })
      .set(panel, { autoAlpha: 1 })
      .fromTo(panel, { clipPath: CLOSED }, { clipPath: OPEN, duration: 1, ease: 'power3.inOut' })
      .fromTo(items(), { y: 80, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 1, stagger: 0.15, ease: 'power3.out' }, '<40%')
      .fromTo(footer, { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: 0.6, ease: 'power3.inOut' }, '<40%');
  }
  function close() {
    if (!isOpen) return;
    isOpen = false;
    setExpanded(false);
    getScroll()?.start();
    panel.setAttribute('inert', '');
    tl?.kill();
    tl = gsap.timeline({ overwrite: true })
      .fromTo(items(), { y: 0, autoAlpha: 1 }, { y: -80, autoAlpha: 0, duration: 0.2, stagger: { each: 0.05 }, ease: 'power2.inOut' })
      .fromTo(footer, { autoAlpha: 1, y: 0 }, { autoAlpha: 0, y: 80, duration: 0.2, ease: 'power2.inOut' }, '<')
      .fromTo(panel, { clipPath: OPEN }, { clipPath: CLOSED, duration: 0.4, ease: 'power2.out' }, '<80%')
      .set(panel, { autoAlpha: 0 });
  }
  panel.setAttribute('inert', '');
  toggle?.addEventListener('click', () => (isOpen ? close() : open()));
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
  panel.querySelectorAll('nav a').forEach((a) => a.addEventListener('click', close));
  return { open, close, get isOpen() { return isOpen; } };
}


/* ─── lib/timeline.js ──────────────────────────────────────────────────────
   Rules — the reference's timeline with the year rail turned into a
   speedometer. Items scatter on a 16-column grid and wipe up as they approach
   the centre; the centre rail + progress line + spinning tip became one
   sticky SVG: a faint arc from the bottom-left corner over the top to the
   bottom-right, an accent needle from the bottom centre, the arc filling
   behind the needle as the section scrolls.
   Geometry (init + resize): W×H = viewport, centre (W/2, H), R = min(W/2, H − 4rem)
     — on 1440×900 R = 720, the arc leaves from the corners; viewBox = "0 0 W H";
     arc `M cx−R H A R R 0 0 1 cx+R H` (sweep 1 → over the top), pathLength 1;
     needle = tapered polygon from the centre to the right (NEEDLE_BASE wide at the
     pivot, 1px at the tip), stopping NEEDLE_GAP rem short of the arc.
   Scrub: one ScrollTrigger over the sticky range (root top top → bottom bottom):
     progress dashoffset 1 → 0 · needle rotate −180° ("0") → 0° ("200").
   Items (desktop): clip inset(0 0 100%) → 0 + img yPercent −30 → 0, sine.inOut,
     scrub top bottom → center center; hover (CSS): siblings .3, image clip inset(5%).
   Mobile (<768): media scale 1 → 1.3 + title fade while the item straddles the centre. */
const NEEDLE_BASE = 3;    /* half-width at the pivot, px → 6px block tapering to 1px */
const NEEDLE_GAP = 3;     /* rem between the tip and the arc — the needle never touches the dial */

function initTimeline(root = document.querySelector('[data-timeline-root]')) {
  if (!root) return null;
  const ctx = gsap.context(() => {
    /* speedometer */
    const svg = root.querySelector('[data-speedo]');
    const rail = root.querySelector('[data-speedo-rail]');
    const progress = root.querySelector('[data-speedo-progress]');
    const needle = root.querySelector('[data-speedo-needle]');
    const geo = { cx: 0, cy: 0, R: 0 };
    const layout = () => {
      if (!svg) return;
      const W = innerWidth, H = innerHeight;
      const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
      const cx = W / 2, cy = H, R = Math.min(W / 2, H - 4 * rem);
      Object.assign(geo, { cx, cy, R });
      svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
      const d = `M ${cx - R} ${cy} A ${R} ${R} 0 0 1 ${cx + R} ${cy}`;
      rail?.setAttribute('d', d);
      progress?.setAttribute('d', d);
      if (needle) {
        const L = Math.max(R * 0.5, R - NEEDLE_GAP * rem), b = NEEDLE_BASE;
        needle.setAttribute('points', `${cx},${cy - b} ${cx + L},${cy - 0.5} ${cx + L},${cy + 0.5} ${cx},${cy + b}`);
      }
    };
    let p = 0;
    const paint = () => {
      if (progress) progress.style.strokeDashoffset = String(1 - p);   /* straight to the style: GSAP rounds this one to whole px */
      if (needle) gsap.set(needle, { rotate: -180 + 180 * p, svgOrigin: `${geo.cx} ${geo.cy}` });
    };
    layout(); paint();
    if (svg) {
      ScrollTrigger.create({
        trigger: root, start: 'top top', end: 'bottom bottom', scrub: true,
        onUpdate: (self) => { p = self.progress; paint(); },
        onRefresh: (self) => { layout(); p = self.progress; paint(); },
      });
    }

    if (isMobile()) {
      const dim = (reveal, title, media) => {
        gsap.to(title, { opacity: 0 }); gsap.to(media, { scale: 1 });
        gsap.to(reveal, { opacity: 0.4, clipPath: 'inset(0% 0% 0% 0%)' });
      };
      const lift = (reveal, title, media) => {
        gsap.to(title, { opacity: 1 }); gsap.to(media, { scale: 1.3 });
        gsap.to(reveal, { opacity: 1, clipPath: 'inset(0% 0% 0% 0%)' });
      };
      gsap.utils.toArray('[data-timeline-item]', root).forEach((item) => {
        const media = item.querySelector('[data-timeline-item-child]');
        const reveal = item.querySelector('[data-timeline-item-reveal]');
        const title = item.querySelector('h3');
        if (!(media && reveal && title)) return;
        gsap.set(reveal, { clipPath: 'inset(0% 0% 100% 0%)' });
        gsap.timeline({
          scrollTrigger: {
            trigger: item, start: 'top center', end: 'bottom center', toggleActions: 'play none none none',
            onEnter: () => lift(reveal, title, media),
            onLeave: () => dim(reveal, title, media),
            onEnterBack: () => lift(reveal, title, media),
            onLeaveBack: () => dim(reveal, title, media),
          },
        })
          .fromTo(media, { scale: 1 }, { scale: 1.3, duration: 0.6 }, 0)
          .fromTo(title, { opacity: 0 }, { opacity: 1, duration: 0.6 }, 0);
      });
      return;
    }

    /* images wipe up as they approach the centre */
    gsap.utils.toArray('[data-timeline-item]', root).forEach((item) => {
      const reveal = item.querySelector('[data-timeline-item-reveal]');
      const img = item.querySelector('[data-timeline-item-frame], [data-timeline-item-reveal] img, [data-timeline-item-reveal] .ph');   /* the frame carries img + raster */
      if (!(reveal && img)) return;
      gsap.timeline({ scrollTrigger: { trigger: item, start: 'top bottom', end: 'center center', scrub: true } })
        .fromTo(reveal, { clipPath: 'inset(0% 0% 100% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', ease: 'sine.inOut' }, 0)
        .fromTo(img, { yPercent: -30 }, { yPercent: 0, ease: 'sine.inOut' }, 0);
    });

    const refresh = () => ScrollTrigger.refresh();
    const ro = new ResizeObserver(refresh);
    ro.observe(document.body); ro.observe(root);
    return () => ro.disconnect();
  }, root);
  return ctx;
}


/* ─── lib/stack.js ─────────────────────────────────────────────────────────
   Stacked slides. n absolutely-stacked slides in a sticky viewport; the
   section is n×100svh tall on mobile, 100vh + pin on desktop.
   Desktop: pinned (pinType transform), scrub, snap to each slide (.8s, magic).
     per transition i → i+1:  slide i clip inset(0 0 100% 0)   (wipes up)
                              slide i img yPercent −15          (parallax out)
                              slide i+1 from yPercent 15        (parallax in)
                              slide i+1 dark layer autoAlpha .9 → 0
                              slide i+1 caption from yPercent −100
   Mobile: scrub .3, no pin; media slides up (−100%) while its inner slides down (+100%),
     images ±15%, caption +100%.
   Order: markup is reversed in the DOM (last slide first) so z-order needs no z-index. */
function initStack(root = document.querySelector('[data-stack]')) {
  if (!root) return null;
  const nodes = root.querySelectorAll('[data-stack-viewport] > div');
  const slides = gsap.utils.toArray(nodes).reverse();
  if (slides.length < 2) return null;
  root.style.setProperty('--stack-height', `${100 * slides.length}svh`);

  const make = (opts) => gsap.timeline({
    ease: 'none',
    scrollTrigger: {
      trigger: root, start: 'top top', scrub: true, invalidateOnRefresh: true,
      snap: { snapTo: 1 / (slides.length - 1), duration: 0.8, ease: 'magic' },
      ...opts,
    },
  });

  const mm = gsap.matchMedia();
  mm.add('(min-width: 768px)', () => {
    const tl = make({ end: `+=${(slides.length - 1) * 100}%`, pin: true, pinType: 'transform' });
    slides.slice(0, -1).forEach((cur, i) => {
      const next = slides[i + 1];
      const layer = next.querySelector('[data-stack-layer]');
      const img = cur.querySelector(':scope img, :scope .ph');
      const caption = next.querySelector('[data-stack-caption]');
      tl.to(cur, { clipPath: 'inset(0 0 100% 0)' })
        .to(img, { yPercent: -15 }, '<')
        .from(next, { yPercent: 15 }, '<')
        .fromTo(layer, { autoAlpha: 0.9 }, { autoAlpha: 0 }, '<')
        .from(caption, { yPercent: -100 }, '<');
    });
  });
  mm.add('(max-width: 767px)', () => {
    const tl = make({ end: 'bottom bottom', scrub: 0.3 });
    slides.slice(0, -1).forEach((cur, i) => {
      const next = slides[i + 1];
      const media = cur.querySelector('[data-stack-media]');
      const inner = cur.querySelector('[data-stack-media-inner]');
      const img = cur.querySelector('[data-stack-media] img, [data-stack-media] .ph');
      const nextImg = next.querySelector('[data-stack-media] img, [data-stack-media] .ph');
      const caption = cur.querySelector('[data-stack-caption]');
      tl.to(media, { yPercent: -100 })
        .to(inner, { yPercent: 100 }, '<')
        .to(img, { yPercent: -15 }, '<')
        .from(nextImg, { yPercent: 15 }, '<')
        .to(caption, { yPercent: 100 }, '<');
    });
  });
  return mm;
}


/* ─── lib/title-mask.js ────────────────────────────────────────────────────
   Wordmark filled with a picture (the footer). The reference filled an SVG
   <text> with a <pattern> holding an <image> twice the pattern's height and
   slid it down on scroll. Ours is turned inside out so the diamond raster can
   live inside the letters: an HTML block clipped to the glyphs
   (clip-path: url(#wordmark-clip)) holds a frame twice its height with the
   B&W still and its raster canvas; the frame parallaxes yPercent −50 → 0
   (scrub 1.25, top bottom → bottom bottom). */
function initTitleMask(marks = document.querySelectorAll('[data-wordmark]')) {
  const tweens = [];
  marks.forEach((mark) => {
    const frame = mark.querySelector('[data-wordmark-frame]');
    if (!frame) return;
    tweens.push(gsap.fromTo(frame, { yPercent: -50 }, {
      yPercent: 0, ease: 'none',
      scrollTrigger: { trigger: mark, start: 'top bottom', end: 'bottom bottom', scrub: 1.25 },
    }));
  });
  const ro = new ResizeObserver(() => ScrollTrigger.refresh());
  ro.observe(document.body);
  return () => { tweens.forEach((t) => t.scrollTrigger?.kill()); ro.disconnect(); };
}


/* ─── lib/transition.js ────────────────────────────────────────────────────
   Page transition, static-site edition. The leave choreography plays, then the
   browser navigates, and the next page fades its cover out on load.
   leave: scrim autoAlpha 0 → .7 (.6s linear) · main y → −100 (.8s power3.inOut)
          panel clip inset(100% 0 0 0) → inset(0) (.8s power3.inOut), panel shows the
          destination's hero picture if the link carries data-hero-src
   enter (next page, if sessionStorage says we came through a leave):
          panel starts fully shown, autoAlpha → 0 (.6s linear), then ScrollTrigger.refresh() */
const KEY = 'new-12:transition';

function initTransition({ main = document.querySelector('main'), scrim = document.querySelector('[data-transition-scrim]'), panel = document.querySelector('[data-transition-panel]') } = {}) {
  if (!(main && scrim && panel)) return;
  const img = panel.querySelector('img');

  /* enter */
  if (sessionStorage.getItem(KEY) === '1') {
    sessionStorage.removeItem(KEY);
    gsap.set(panel, { clipPath: 'inset(0% 0% 0% 0%)', autoAlpha: 1 });
    gsap.timeline({ onComplete: () => requestAnimationFrame(() => ScrollTrigger.refresh()) })
      .set(main, { clearProps: 'transform' })
      .set(scrim, { autoAlpha: 0 })
      .to(panel, { autoAlpha: 0, duration: 0.6, ease: 'none' })
      .set(panel, { clipPath: 'inset(0% 0% 100% 0%)' });
  }

  /* leave */
  document.addEventListener('click', (e) => {
    const a = e.target.closest('a[href]');
    if (!a || a.target === '_blank' || a.hasAttribute('download')) return;
    if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
    const url = new URL(a.href, location.href);
    if (url.origin !== location.origin) return;
    if (url.pathname === location.pathname && url.hash) return; // in-page anchor → the scroll module
    e.preventDefault();
    document.documentElement.classList.add('is-changing');
    if (img) {
      const src = a.dataset.heroSrc;
      if (src) { img.setAttribute('src', src); gsap.set(img, { autoAlpha: 1 }); }
      else gsap.set(img, { autoAlpha: 0 });
    }
    gsap.set([scrim, panel, main], { clearProps: 'all' });
    gsap.timeline({ onComplete: () => { sessionStorage.setItem(KEY, '1'); location.href = url.href; } })
      .set(main, { y: 0 })
      .set(panel, { autoAlpha: 1 })
      .set(scrim, { autoAlpha: 0 })
      .to(scrim, { autoAlpha: 0.7, duration: 0.6, ease: 'none' })
      .to(main, { y: -100, duration: 0.8, ease: 'power3.inOut' }, '<')
      .fromTo(panel, { clipPath: 'inset(100% 0% 0% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 0.8, ease: 'power3.inOut' }, '<');
  });
}


/* ─── lib/pill-nav.js ──────────────────────────────────────────────────────
   Floating pill: a white pill fixed at the bottom centre. Home uses it as the
   CTA ("Pull up" → #pull-up): hidden at the top of the page, slides in with
   the first pixels of scroll (hero top passes −4%), hides while the footer is
   on screen. With a [data-pill-toggle] + nav inside it Flip-expands into a
   list (the reference's project switcher) — kept, unused. */
function initPillNav(root = document.querySelector('[data-pill-nav]'), { showAfter = document.querySelector('[data-hero]') } = {}) {
  if (!root) return null;
  const box = root.querySelector('[data-flip-id]');
  const list = root.querySelector('nav');
  const toggle = root.querySelector('[data-pill-toggle]');
  let open = false;

  /* visible from the first scroll (hero top passes 4% above the viewport) to the footer */
  const footer = document.querySelector('[data-footer-spy]');
  let pastHero = !showAfter, atFooter = false;
  const sync = () => root.setAttribute('data-hidden', pastHero && !atFooter ? 'false' : 'true');
  if (showAfter) {
    ScrollTrigger.create({
      trigger: showAfter, start: 'top -4%',
      onEnter: () => { pastHero = true; sync(); },
      onLeaveBack: () => { pastHero = false; sync(); },
    });
  }
  if (footer) {
    ScrollTrigger.create({
      trigger: footer, start: 'top bottom',
      onEnter: () => { atFooter = true; sync(); },
      onLeaveBack: () => { atFooter = false; sync(); },
    });
  }

  if (!(toggle && list && box)) return { get isOpen() { return false; } };
  toggle.addEventListener('click', () => {
    open = !open;
    const state = Flip.getState(box);
    root.toggleAttribute('data-open', open);
    if (open) list.removeAttribute('inert'); else list.setAttribute('inert', '');
    Flip.from(state, { duration: 0.6, ease: 'magic', absolute: false });
    gsap.fromTo(list.querySelectorAll('li'), { autoAlpha: open ? 0 : 1, x: open ? -16 : 0 }, { autoAlpha: open ? 1 : 0, x: 0, duration: 0.4, stagger: 0.04, ease: 'magic' });
  });
  return { get isOpen() { return open; } };
}


/* ─── main.js ──────────────────────────────────────────────────────────────
   Entry. Order matters: eases/plugins → scroll (owner of Lenis) → split
   (needs fonts) → spies → sections. Every module is a no-op when its markup
   is absent. */
const scroll = initScroll();
initSplit();
initImageLoad();
initFooterSpy();
const heroVideo = initHeroVideo();
/* diamond raster over the clip and the Rules plates.
   Roll back: set FX = false — or open the page with ?fx=off to compare — and the hero is the
   plain colour clip again, the plates static B&W stills */
const FX = new URLSearchParams(location.search).get('fx') !== 'off';
const heroFx = FX ? initHeroFx({ video: heroVideo.video, ready: heroVideo.ready, onFrame: heroVideo.onFrame }) : null;
const hero = initHero({
  /* the scroll-scrubbed hero video: 0→1.5 from the stage becomes 0→1 of the clip;
     the intro waits for the raster's own trailing source too (each has a 4s timeout) */
  ready: Promise.all([heroVideo.ready, heroFx?.ready ?? Promise.resolve()]),
  onProgress: (p) => {
    document.documentElement.style.setProperty('--hero-p', p.toFixed(3));
    heroVideo.scrub(Math.min(1, Math.max(0, p / 1.5)));
    heroFx?.progress(p);
  },
});
const menu = initMenu();
initTimeline();
const plates = FX ? initPlateFx() : [];
initStack();
initTitleMask();
initTransition();
initPillNav();

/* dev hook (house convention) */
window.__site = {
  scroll, gsap, ScrollTrigger, menu, hero, heroVideo, fx: heroFx, plates, replay,
  refresh: () => ScrollTrigger.refresh(),
  drive: (y) => getScroll()?.scrollTo(y, { immediate: true }),
  state: () => ({
    y: scroll?.lenisInstance?.scroll,
    stopped: scroll?.lenisInstance?.isStopped,
    inview: document.querySelectorAll('.is-inview').length,
    triggers: ScrollTrigger.getAll().length,
    video: heroVideo.video && { t: +heroVideo.video.currentTime.toFixed(3), d: heroVideo.video.duration, seeking: heroVideo.video.seeking, ready: heroVideo.video.readyState },
  }),
};
</script>
</body>
</html>
[/hidecontent]
← 返回首页