{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "intro-loader",
  "type": "registry:ui",
  "title": "Intro Loader",
  "description": "A page-load intro that counts to 100 with the real loading while a flipbook of your work plays in a small frame, then grows the last image into its place on the page. It doesn't fade out: its last frame is the page's first.",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/intro-loader.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * INTRO LOADER STORYBOARD   (on a fast connection)\n *\n *    0.0s  the first paint is already the loader, in the page's\n *          own background. A tiny inline script decides before\n *          the page under it is drawn, so nothing flashes, even\n *          before React loads. The counter rises in at 000\n *  0→1.6s  count: 000 to 100 in uneven jumps, following the fonts\n *          and images. Each digit rolls up into place\n *          flip: each image wipes up over the last in the center\n *          frame, on the count, each one a little sooner than the\n *          one before. The last comes in at 88\n *    1.6s  breath: 100. The counter slides up out of its mask and\n *          the last image holds\n *  2.2→3.1 handoff: the last image grows from the frame to exactly\n *          where the target sits on the page. It was laid out at\n *          the target's size all along, only scaled down and\n *          masked to the frame, so it lands as the target itself.\n *          The loader's background clears as it lands\n *    3.1s  reveal: the loader is gone, lines rise into place, the\n *          nav drops in and scrolling unlocks\n *\n *  Everything that moves is a Web Animation of a transform, a\n *  clip or an opacity. The only script that runs each frame is\n *  the count, so it stays smooth while React hydrates.\n * ───────────────────────────────────────────────────────── */\n\nconst INTRO = {\n  steps: [0.1, 0.38], // s between count updates, picked at random, so it jumps unevenly\n  crawl: 0.85, // how far the count creeps by maxDuration while assets are still loading\n  enter: { duration: 0.9, ease: [0.23, 1, 0.32, 1] }, // the counter and brand rising in\n  roll: { duration: 0.5, ease: [0.23, 1, 0.32, 1] }, // a digit rolling to its next value; it lands well before the next jump\n  pace: 0.7, // each image shows for this share of the time the one before did\n  last: 0.88, // where in the count the last image comes in\n  first: 0.42, // s the first image shows, at least; later ones shrink from it by `pace`\n  gap: 0.12, // s between letters, and between images left at 100, at least\n  wipe: { duration: 0.75, ease: [0.23, 1, 0.32, 1], zoom: 1.25 }, // an image wiping up as it settles\n  letters: { from: 0.04, to: 0.8, duration: 0.9, ease: [0.23, 1, 0.32, 1] }, // the mark, across the count\n  settled: 0.5, // share of a wipe or rise after which the piece counts as in\n  hold: 0.12, // s at 100 before the counter leaves\n  leave: { duration: 0.6, ease: [0.77, 0, 0.175, 1] },\n  breath: 0.28, // s the last image holds, still, before the handoff\n  grow: { duration: 0.9, ease: [0.77, 0, 0.175, 1], clear: 0.4 }, // `clear`: last share, over which the background goes\n  rise: { duration: 0.9, ease: [0.23, 1, 0.32, 1], stagger: 0.07 },\n  out: { duration: 0.35, ease: [0.23, 1, 0.32, 1] }, // fading out on a skip, or with nowhere to land\n  fade: { duration: 0.5, ease: [0.23, 1, 0.32, 1], delay: 0.15 }, // reduced motion\n  // Resting places while hidden. Keep them in step with the classes that\n  // set them before the script runs.\n  hidden: {\n    riser: \"translateY(140%)\",\n    leave: \"translateY(-140%)\",\n    glyph: \"translateY(115%)\",\n    below: \"translateY(115%)\",\n    above: \"translateY(-115%)\",\n  },\n} as const;\n\ntype Corner = \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\";\n\ntype Plan = {\n  once: boolean;\n  key: string;\n  min: number;\n  max: number;\n  skippable: boolean;\n  contained: boolean;\n  skip: boolean;\n  images: string[];\n  t: typeof INTRO;\n};\n\ntype Handle = {\n  done: boolean;\n  on: (listener: () => void) => () => void;\n  destroy: () => void;\n};\n\ntype LoaderRoot = HTMLElement & { __introLoader?: Handle };\n\n/** A box in the loader's coordinates, with its top-left corner radius. */\ntype Spot = { x: number; y: number; w: number; h: number; r: number };\n\n/**\n * The whole sequence, from the first digit to the last line settling. It runs\n * as an inline <script> while the HTML is still parsing, so the loader is up\n * on the first paint, and from an effect when the component mounts on the\n * client.\n *\n * It's serialized with toString(), so it must stay self-contained: no outer\n * variables, no imports, nothing a compiler would turn into a helper (async,\n * object spread). State lives on the root as data-intro-loader: play, then\n * reveal, then done; or fade, out or skip.\n */\nfunction introLoader(root: HTMLElement, o: Plan): Handle {\n  const t = o.t;\n  let listeners: Array<() => void> = [];\n  let cleanups: Array<() => void> = [];\n  let anims: Animation[] = [];\n  let frame = 0;\n  // 0 counting, 1 at 100, 2 handing off, 3 revealing or fading out, 4 done\n  let phase = 0;\n\n  const release = () => {\n    cleanups.forEach((c) => c());\n    cleanups = [];\n  };\n  const handle: Handle = {\n    done: false,\n    on(listener) {\n      if (handle.done) listener();\n      else listeners.push(listener);\n      return () => {\n        listeners = listeners.filter((l) => l !== listener);\n      };\n    },\n    destroy() {\n      phase = 4;\n      cancelAnimationFrame(frame);\n      release();\n      anims.forEach((a) => a.cancel());\n      anims = [];\n      listeners = [];\n    },\n  };\n  (root as LoaderRoot).__introLoader = handle;\n\n  const finish = () => {\n    if (handle.done) return;\n    handle.done = true;\n    const due = listeners;\n    listeners = [];\n    due.forEach((l) => l());\n  };\n  const state = (value: string) => root.setAttribute(\"data-intro-loader\", value);\n\n  // Seen this session, or arriving at a section by its link: straight in.\n  let seen = false;\n  try {\n    seen = o.once && sessionStorage.getItem(o.key) === \"1\";\n  } catch {}\n  if (\n    o.skip ||\n    seen ||\n    typeof root.animate !== \"function\" ||\n    (!o.contained && location.hash.length > 1)\n  ) {\n    state(\"skip\");\n    finish();\n    return handle;\n  }\n  const reduce = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  state(reduce ? \"fade\" : \"play\");\n\n  // The loader wears the page's own background, so where the page shows\n  // through at the end there's no seam: the nearest one that isn't clear.\n  for (let el: HTMLElement | null = root; el; el = el.parentElement) {\n    const bg = getComputedStyle(el).backgroundColor;\n    if (bg && bg !== \"transparent\" && !/^rgba\\(.*,\\s*0\\)$|\\/\\s*0\\)$/.test(bg)) {\n      root.style.setProperty(\"--intro-loader-bg\", bg);\n      break;\n    }\n  }\n\n  const run = (\n    el: Element,\n    keyframes: Keyframe[],\n    timing: { duration: number; ease: readonly number[] },\n    delay = 0,\n    fill: FillMode = \"backwards\",\n  ) => {\n    const a = el.animate(keyframes, {\n      duration: timing.duration * 1000,\n      delay: delay * 1000,\n      easing: `cubic-bezier(${timing.ease.join(\",\")})`,\n      fill,\n    });\n    anims.push(a);\n    return a;\n  };\n\n  // The page is still parsing when this first runs, so the loader may not\n  // exist yet. Look again every frame until parsing is done.\n  let parsed = false;\n  let cover: HTMLElement | null = null;\n  let box: HTMLElement | null = null; // the center frame\n  let flier: HTMLElement | null = null; // the last image\n  let mark: HTMLElement | null = null;\n  let word: HTMLElement | null = null; // the mark set whole, kerned and unseen\n  let counter: HTMLElement | null = null;\n  let risers: HTMLElement[] = [];\n  let digits: HTMLElement[] = [];\n  let shots: HTMLElement[] = [];\n  let glyphs: HTMLElement[] = [];\n  const find = () => {\n    if (parsed) return;\n    parsed = document.readyState !== \"loading\";\n    const c = root.querySelector<HTMLElement>(\"[data-intro-loader-cover]\");\n    cover = c;\n    if (!c) return;\n    const all = (selector: string) => Array.from(c.querySelectorAll<HTMLElement>(selector));\n    box = c.querySelector(\"[data-intro-loader-frame]\");\n    flier = c.querySelector(\"[data-intro-loader-flier]\");\n    mark = c.querySelector(\"[data-intro-loader-mark]\");\n    word = c.querySelector(\"[data-intro-loader-word]\");\n    counter = c.querySelector(\"[data-intro-loader-counter]\");\n    risers = all(\"[data-intro-loader-in]\");\n    digits = all(\"[data-intro-loader-digit]\");\n    shots = all(\"[data-intro-loader-shot]\");\n    glyphs = all(\"[data-intro-loader-glyph]\");\n    fill();\n  };\n\n  const listen = (target: EventTarget, type: string, fn: (event: Event) => void) => {\n    target.addEventListener(type, fn, { passive: false });\n    cleanups.push(() => target.removeEventListener(type, fn));\n  };\n\n  // A click or a key goes straight to the page. Modifiers don't, so a\n  // screenshot shortcut doesn't skip the moment it's trying to catch.\n  const skip = (event: Event) => {\n    if (!o.skippable || phase > 2) return;\n    if (event.type === \"keydown\") {\n      const k = event as KeyboardEvent;\n      if (k.metaKey || k.ctrlKey || k.altKey || /^(Shift|Meta|Control|Alt|CapsLock|Fn|OS)$/.test(k.key)) return;\n    } else if ((event as MouseEvent).button !== 0) {\n      return;\n    }\n    out();\n  };\n  if (!reduce) {\n    if (o.contained) {\n      listen(root, \"click\", skip);\n    } else {\n      // Lock the page without touching its styles, so nothing shifts when the\n      // scrollbar would otherwise come and go.\n      const keys = [\" \", \"PageUp\", \"PageDown\", \"Home\", \"End\", \"ArrowUp\", \"ArrowDown\", \"ArrowLeft\", \"ArrowRight\"];\n      const block = (event: Event) => {\n        if (event.type !== \"keydown\" || keys.indexOf((event as KeyboardEvent).key) > -1) {\n          event.preventDefault();\n        }\n      };\n      listen(window, \"wheel\", block);\n      listen(window, \"touchmove\", block);\n      listen(window, \"keydown\", (event) => {\n        block(event);\n        skip(event);\n      });\n      listen(window, \"click\", skip);\n    }\n  }\n\n  // A loader over part of the page waits until it's on screen.\n  let started = !o.contained;\n  if (!started) {\n    const io = new IntersectionObserver(\n      (entries) => {\n        if (!entries[0].isIntersecting) return;\n        started = true;\n        io.disconnect();\n        fetchImages();\n      },\n      { rootMargin: \"0px 0px -15% 0px\" },\n    );\n    io.observe(root);\n    cleanups.push(() => io.disconnect());\n  }\n\n  // Loading: the flipbook, fetched as soon as it starts, before the HTML that\n  // shows it has even arrived; then the fonts and every image in view on the\n  // page. A loader further down the page fetches nothing until it's on\n  // screen, so it doesn't hold up one that is.\n  let total = 0;\n  let loaded = 0;\n  const track = (promise: Promise<unknown>) => {\n    total++;\n    const done = () => {\n      loaded++;\n    };\n    promise.then(done, done);\n  };\n  const ready: Array<boolean | undefined> = []; // per image: decoded, or failed\n  let fetched = false;\n  // The loader's own images carry their address in data-src until it plays.\n  function fill() {\n    if (!fetched || !cover) return;\n    cover.querySelectorAll(\"img[data-src]\").forEach((img) => {\n      if (!img.getAttribute(\"src\")) img.setAttribute(\"src\", img.getAttribute(\"data-src\") || \"\");\n    });\n  }\n  function fetchImages() {\n    if (fetched || reduce) return;\n    fetched = true;\n    fill();\n    o.images.forEach((src, i) => {\n      const img = new Image();\n      img.src = src;\n      track(\n        img.decode().then(\n          () => {\n            ready[i] = true;\n          },\n          () => {\n            ready[i] = img.complete && img.naturalWidth > 0;\n          },\n        ),\n      );\n    });\n  }\n  if (started) fetchImages();\n  const view = () =>\n    o.contained ? root.getBoundingClientRect() : { top: 0, left: 0, bottom: innerHeight, right: innerWidth };\n  let scanned = false;\n  const scan = () => {\n    scanned = true;\n    if (document.fonts) track(document.fonts.ready);\n    const v = view();\n    root.querySelectorAll(\"img\").forEach((img) => {\n      if (cover && cover.contains(img)) return;\n      // An image in a line is pushed out of its mask; where it shows is the mask.\n      const r = (img.closest(\"[data-intro-loader-line]\") || img).getBoundingClientRect();\n      if (r.bottom < v.top || r.top > v.bottom || r.right < v.left || r.left > v.right) return;\n      track(img.decode());\n    });\n  };\n\n  const spot = (el: Element): Spot => {\n    const r = el.getBoundingClientRect();\n    const c = cover ? cover.getBoundingClientRect() : r;\n    return {\n      x: r.left - c.left,\n      y: r.top - c.top,\n      w: r.width,\n      h: r.height,\n      r: parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0,\n    };\n  };\n\n  // Where the target sits under the loader, if it's on screen. A target with\n  // no box of its own (display: contents) is measured by its first child.\n  const target = () => {\n    const marker = root.querySelector(\"[data-intro-loader-target]\");\n    if (!marker || !cover || cover.contains(marker)) return null;\n    // The page sits at its top under the loader, wherever the browser restored it to.\n    if (!o.contained && (scrollX || scrollY)) scrollTo({ top: 0, left: 0, behavior: \"instant\" });\n    const el = marker.getClientRects().length ? marker : marker.firstElementChild;\n    if (!el) return null;\n    const s = spot(el);\n    const c = cover.getBoundingClientRect();\n    if (s.w < 1 || s.h < 1 || s.x + s.w <= 0 || s.y + s.h <= 0 || s.x >= c.width || s.y >= c.height) {\n      return null;\n    }\n    if (!s.r && el.firstElementChild) {\n      s.r = parseFloat(getComputedStyle(el.firstElementChild).borderTopLeftRadius) || 0;\n    }\n    const media = el.matches(\"img,video\") ? el : el.querySelector(\"img,video\");\n    return { spot: s, fit: media ? getComputedStyle(media).objectPosition : \"\" };\n  };\n\n  // The last image is laid out at the target's size from the start, then\n  // scaled down and masked to the frame. Landing is only undoing that, so its\n  // last frame is the target, pixel for pixel, at any screen size.\n  let aim: { to: Spot | null; mask: string; from: string } | null = null;\n  const place = () => {\n    if (!flier || !box) return false;\n    const f = spot(box);\n    if (!f.w || !f.h) return false;\n    const found = target();\n    const b = found ? found.spot : f;\n    const s = Math.max(f.w / b.w, f.h / b.h);\n    const w = f.w / s; // the frame, in the image's own pixels\n    const h = f.h / s;\n    const l = (b.w - w) / 2;\n    const top = (b.h - h) / 2;\n    const r = f.r / s;\n    flier.style.left = `${b.x}px`;\n    flier.style.top = `${b.y}px`;\n    flier.style.width = `${b.w}px`;\n    flier.style.height = `${b.h}px`;\n    flier.style.transform = `translate(${f.x - b.x - s * l}px, ${f.y - b.y - s * top}px) scale(${s})`;\n    flier.style.clipPath = `inset(${top}px ${l}px ${top}px ${l}px round ${r}px)`;\n    const img = flier.firstElementChild as HTMLElement | null;\n    if (img) {\n      img.style.objectPosition = found ? found.fit : \"\";\n      img.style.transformOrigin = `${l + w / 2}px ${top + h / 2}px`;\n    }\n    aim = {\n      to: found ? found.spot : null,\n      mask: flier.style.clipPath,\n      from: `inset(${b.h - top}px ${l}px ${top}px ${l}px round ${r}px)`,\n    };\n    return true;\n  };\n\n  // Split letters lose their kerning. Line each one up with where it falls in\n  // the whole word, so the mark lands exactly on a logo set in the same type.\n  const kern = () => {\n    const text = word && word.firstChild;\n    if (!word || !text || text.nodeType !== 3) return;\n    const range = document.createRange();\n    const origin = word.getBoundingClientRect().left;\n    let offset = 0;\n    glyphs.forEach((glyph) => {\n      const mask = glyph.parentElement;\n      const length = (glyph.textContent || \"\").length;\n      if (!mask || offset + length > (text as Text).length) return;\n      range.setStart(text, offset);\n      range.setEnd(text, offset + length);\n      offset += length;\n      mask.style.transform = `translateX(${range.getBoundingClientRect().left - origin - mask.offsetLeft}px)`;\n    });\n  };\n\n  const enter = () => {\n    risers.forEach((el) => {\n      if (el.style.transform) return;\n      el.style.transform = \"none\";\n      run(el, [{ transform: t.hidden.riser }, { transform: \"none\" }], t.enter);\n    });\n  };\n\n  // Each digit rolls forward to its next value, through the ones between.\n  // A strip holds 0-9 twice, so 7 to 2 rolls on through 8, 9 and 0.\n  const roll = (value: number) => {\n    const text = String(value).padStart(digits.length, \"0\");\n    digits.forEach((strip, i) => {\n      const size = strip.offsetHeight / 20;\n      if (!size) return;\n      let at = -new DOMMatrixReadOnly(getComputedStyle(strip).transform).m42 / size;\n      if (at > 9.99) at -= 10; // the same digit, on the first run\n      let to = Number(text.charAt(i));\n      while (to < at - 0.01) to += 10;\n      if (Math.abs(to - at) < 0.01) return;\n      strip.getAnimations().forEach((a) => a.cancel());\n      strip.style.transform = `translateY(${-to * 5}%)`;\n      run(strip, [{ transform: `translateY(${-at * 5}%)` }, { transform: strip.style.transform }], t.roll);\n    });\n  };\n\n  // Clock, in seconds of visible time: a backgrounded tab pauses the intro\n  // instead of jumping it to the end when it comes back. A slow frame still\n  // counts in full, so a phone busy hydrating the page doesn't stretch it.\n  let elapsed = 0;\n  let last = 0;\n  let goal = 0;\n  let value = 0;\n  let step = 0; // when the count may next move\n  let landed = 0; // when it reached 100\n  let shot = 0; // next image to come in\n  let glyph = 0; // next letter to rise\n  let next = 0; // when the next image or letter may come in\n  let settled = 0; // when the subject is all in\n  let marked = false; // remembered for the session, from the first counted frame\n  const n = o.images.length;\n\n  const count = () => {\n    const cap = Math.max(o.max, o.min);\n    // The count may not pass the clock, so it never lands before minDuration,\n    // nor pass the loading, which creeps on its own so it never stalls.\n    const real = scanned ? (total ? loaded / total : 1) : 0;\n    const done = (scanned && loaded >= total) || elapsed >= cap;\n    const clock = o.min > 0 ? Math.min(1, elapsed / o.min) : 1;\n    const creep = real + (1 - real) * t.crawl * (1 - Math.exp((-3 * elapsed) / cap));\n    goal = Math.max(goal, Math.min(clock, done ? 1 : creep));\n    if (elapsed >= step || goal >= 1) {\n      step = elapsed + t.steps[0] + Math.random() * (t.steps[1] - t.steps[0]);\n      const shown = Math.floor(goal * 100 + 1e-6);\n      if (shown > value) {\n        value = shown;\n        roll(value);\n      }\n    }\n    if (value < 100 || !parsed) return;\n    phase = 1;\n    landed = elapsed;\n    if (counter) {\n      counter.getAnimations().forEach((a) => a.cancel());\n      counter.style.transform = t.hidden.leave;\n      run(counter, [{ transform: \"none\" }, { transform: t.hidden.leave }], t.leave, t.hold);\n    }\n    if (!n && !glyphs.length) settled = landed + t.hold + t.leave.duration * t.settled;\n  };\n\n  // The subject keeps time with the count: images each due a little sooner\n  // than the one before, letters evenly. Each image also holds a little less\n  // than the one before, so one that loads late doesn't bunch up the rest.\n  // At 100 the loaded images left go by in a quick flurry; out of time, it\n  // goes straight to the last.\n  const pace = () => {\n    if (elapsed < next) return;\n    const p = value / 100;\n    if (shot < n) {\n      if (elapsed >= Math.max(o.max, o.min)) shot = Math.max(shot, n - 1);\n      const i = shot;\n      const due = n < 2 ? 0 : (t.last * (1 - Math.pow(t.pace, i))) / (1 - Math.pow(t.pace, n - 1));\n      if (value < 100 && (p < due || ready[i] === undefined)) return;\n      const el = i < n - 1 ? shots[i] : flier;\n      if (!el) return; // not parsed yet\n      shot++;\n      if (i < n - 1 && !ready[i]) return; // didn't load: skip it\n      if (i === n - 1 && !place()) return;\n      const to = i < n - 1 ? \"inset(0px)\" : aim ? aim.mask : \"\";\n      el.style.clipPath = to;\n      run(el, [{ clipPath: i < n - 1 ? \"inset(100% 0px 0px 0px)\" : aim ? aim.from : \"\" }, { clipPath: to }], t.wipe);\n      if (el.firstElementChild) {\n        run(el.firstElementChild, [{ transform: `scale(${t.wipe.zoom})` }, { transform: \"none\" }], t.wipe);\n      }\n      next = elapsed + (value >= 100 ? t.gap : Math.max(t.gap, t.first * Math.pow(t.pace, i)));\n      if (shot >= n) settled = elapsed + t.wipe.duration * t.settled;\n    } else if (glyph < glyphs.length) {\n      const due = t.letters.from + ((t.letters.to - t.letters.from) * glyph) / Math.max(1, glyphs.length - 1);\n      if (value < 100 && p < due) return;\n      kern();\n      const el = glyphs[glyph++];\n      el.style.transform = \"none\";\n      run(el, [{ transform: t.hidden.glyph }, { transform: \"none\" }], t.letters);\n      next = elapsed + t.gap;\n      if (glyph >= glyphs.length) settled = elapsed + t.letters.duration * t.settled;\n    }\n  };\n\n  // The loader's background clears over the end of the landing, so wherever\n  // it differs from the page's, it eases into it instead of cutting.\n  const clear = () => {\n    if (!cover) return;\n    run(\n      cover,\n      [{ backgroundColor: getComputedStyle(cover).backgroundColor }, { backgroundColor: \"transparent\" }],\n      { duration: t.grow.duration * t.grow.clear, ease: t.out.ease },\n      t.grow.duration * (1 - t.grow.clear),\n      \"both\",\n    );\n  };\n\n  const grow = () => {\n    phase = 2;\n    cancelAnimationFrame(frame);\n    // Measured again: fonts or a resize may have moved the target since.\n    if (n && flier && box && place() && aim && aim.to) {\n      box.style.visibility = \"hidden\"; // covered right now; it would show around the image as it moves\n      const from = { transform: flier.style.transform, clipPath: flier.style.clipPath };\n      flier.style.transform = \"none\";\n      flier.style.clipPath = `inset(0px round ${aim.to.r}px)`;\n      run(flier, [from, { transform: \"none\", clipPath: flier.style.clipPath }], t.grow).finished.then(reveal, () => {});\n      clear();\n      return;\n    }\n    const found = mark && glyphs.length ? target() : null;\n    if (mark && found) {\n      kern();\n      // The mark scales to the target's width, centered on it. Lines center\n      // their glyphs, so centers line up whatever the line heights. It scales\n      // from its own center, so it flies in a straight line.\n      const letters = spot(word || glyphs[0]);\n      const own = spot(mark);\n      const to = found.spot;\n      const s = to.w / letters.w;\n      const ox = own.x + own.w / 2;\n      const oy = own.y + own.h / 2;\n      const x = to.x + to.w / 2 - ox - s * (letters.x + letters.w / 2 - ox);\n      const y = to.y + to.h / 2 - oy - s * (letters.y + letters.h / 2 - oy);\n      mark.style.transform = `translate(${x}px, ${y}px) scale(${s})`;\n      run(mark, [{ transform: \"none\" }, { transform: mark.style.transform }], t.grow).finished.then(reveal, () => {});\n      clear();\n      return;\n    }\n    // Nowhere to land: the loader fades into the page.\n    if (cover) run(cover, [{ opacity: 1 }, { opacity: 0 }], t.out, 0, \"forwards\").finished.then(reveal, () => {});\n    else reveal();\n  };\n\n  const settle = () => {\n    phase = 4;\n    state(\"done\");\n    anims.forEach((a) => a.cancel());\n    anims = [];\n  };\n\n  // The loader is gone. Each line in view rises, a stagger apart in reading\n  // order; lines dropping from above, like a nav, keep their own count.\n  const reveal = () => {\n    if (phase > 2) return;\n    phase = 3;\n    release();\n    const v = view();\n    const rising: Animation[] = [];\n    let below = 0;\n    let above = 0;\n    root.querySelectorAll<HTMLElement>(\"[data-intro-loader-line]\").forEach((mask) => {\n      const line = mask.firstElementChild;\n      if (!line || (cover && cover.contains(mask))) return;\n      const r = mask.getBoundingClientRect();\n      if (r.bottom < v.top || r.top > v.bottom || r.right < v.left || r.left > v.right) return;\n      const up = mask.getAttribute(\"data-intro-loader-line\") !== \"above\";\n      const delay = (up ? below++ : above++) * t.rise.stagger;\n      rising.push(\n        run(line, [{ transform: up ? t.hidden.below : t.hidden.above }, { transform: \"none\" }], t.rise, delay),\n      );\n    });\n    state(\"reveal\");\n    finish();\n    Promise.all(rising.map((a) => a.finished)).then(settle, () => {});\n  };\n\n  // Skipped: the page is there, at rest, as the loader fades off it.\n  const out = () => {\n    if (phase > 2) return;\n    phase = 3;\n    cancelAnimationFrame(frame);\n    release();\n    state(\"out\");\n    finish();\n    if (!cover) return settle();\n    cover.style.pointerEvents = \"none\";\n    run(cover, [{ opacity: getComputedStyle(cover).opacity }, { opacity: 0 }], t.out, 0, \"forwards\").finished.then(\n      settle,\n      () => {},\n    );\n  };\n\n  const tick = (now: number) => {\n    frame = requestAnimationFrame(tick);\n    find();\n    const dt = last ? Math.min(now - last, 500) / 1000 : 0;\n    last = now;\n    if (!started || !cover) return;\n    if (!marked && o.once) {\n      try {\n        sessionStorage.setItem(o.key, \"1\");\n      } catch {}\n    }\n    marked = true;\n    elapsed += dt;\n\n    // Reduced motion: no loader, only a quick crossfade into the page.\n    if (reduce) {\n      if (elapsed < t.fade.delay) return;\n      cancelAnimationFrame(frame);\n      phase = 3;\n      finish();\n      run(cover, [{ opacity: 1 }, { opacity: 0 }], t.fade, 0, \"forwards\").finished.then(settle, () => {});\n      return;\n    }\n\n    enter();\n    if (parsed && !scanned) scan();\n    if (phase === 0) count();\n    if (phase < 2) pace();\n    if (phase === 1 && shot >= n && glyph >= glyphs.length && elapsed >= Math.max(landed, settled) + t.breath) {\n      grow();\n    }\n  };\n  frame = requestAnimationFrame(tick);\n\n  return handle;\n}\n\nconst subscribe = () => () => {};\n\ntype Store = {\n  get: () => boolean;\n  set: () => void;\n  subscribe: (listener: () => void) => () => void;\n};\n\nfunction createStore(): Store {\n  let done = false;\n  let listeners: Array<() => void> = [];\n  return {\n    get: () => done,\n    set() {\n      if (done) return;\n      done = true;\n      listeners.forEach((l) => l());\n    },\n    subscribe(listener) {\n      listeners.push(listener);\n      return () => {\n        listeners = listeners.filter((l) => l !== listener);\n      };\n    },\n  };\n}\n\nconst IntroContext = React.createContext<Store | null>(null);\n\n/**\n * True once the loader has handed off to the page, or straight away when it's\n * skipped. False while rendering on the server. Start a hero's own entrance\n * with it. Outside an IntroLoader it's always true.\n */\nexport function useIntroDone() {\n  const store = React.useContext(IntroContext);\n  return React.useSyncExternalStore(\n    store ? store.subscribe : subscribe,\n    () => (store ? store.get() : true),\n    () => !store,\n  );\n}\n\nconst CORNERS: Record<Corner, string> = {\n  \"top-left\": \"top-0 left-0\",\n  \"top-right\": \"top-0 right-0 text-right\",\n  \"bottom-left\": \"bottom-0 left-0\",\n  \"bottom-right\": \"bottom-0 right-0 text-right\",\n};\n\nexport interface IntroLoaderProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * The page. Wrap where the last image lands in IntroLoaderTarget, and the\n   * lines that rise in after it in IntroLoaderLine.\n   */\n  children?: React.ReactNode;\n  /** The flipbook, in order. The last one lands on the target, so make it the image the target shows. */\n  images?: string[];\n  /**\n   * Instead of images, a mark that rises letter by letter and lands on the\n   * target, like a wordmark flying into the nav. Set it in the same type as\n   * the target. A string is split into letters; anything else rises whole.\n   */\n  mark?: React.ReactNode;\n  /** Your name or logo, in the loader's top-left corner. Set it where the page's logo sits. */\n  brand?: React.ReactNode;\n  /** Shown in place of the number, like a season. It rises in and leaves at 100 like the number. */\n  label?: React.ReactNode;\n  /** The corner the number sits in, or false for none. */\n  counter?: Corner | false;\n  /** Shortest the count can take, in seconds, even when everything is already loaded. */\n  minDuration?: number;\n  /** Longest it waits for loading, in seconds, before landing anyway. */\n  maxDuration?: number;\n  /** Play once per browser session, so reloads and the back button go straight to the page. */\n  once?: boolean;\n  /** sessionStorage key that remembers it played. */\n  storageKey?: string;\n  /** A click or any key goes straight to the page. */\n  skippable?: boolean;\n  /** Cover this element instead of the screen, without locking the page's scroll. Starts once it's on screen. */\n  contained?: boolean;\n  /** Called as the loader hands off to the page, or right away when it's skipped. */\n  onComplete?: () => void;\n  /** Classes for the loader, such as its padding, font and color. Its background defaults to the page's. */\n  overlayClassName?: string;\n  /** Classes for the number or label, such as its size. */\n  counterClassName?: string;\n  /** Classes for the center frame the images play in, such as its size, shape and corners. */\n  frameClassName?: string;\n  /** Classes for the mark, such as its font and size. */\n  markClassName?: string;\n}\n\n/**\n * A full-screen intro that counts to 100 as the page loads while a flipbook of\n * images plays in a small frame, then grows the last image into its place on\n * the page. Reads its props once, on mount; change its `key` to play it again.\n */\nexport function IntroLoader({\n  children,\n  images = [],\n  mark,\n  brand,\n  label,\n  counter = \"bottom-left\",\n  minDuration = 1.6,\n  maxDuration = 4,\n  once = true,\n  storageKey = \"intro-loader\",\n  skippable = true,\n  contained = false,\n  onComplete,\n  overlayClassName,\n  counterClassName,\n  frameClassName,\n  markClassName,\n  className,\n  nonce,\n  ...props\n}: IntroLoaderProps) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  // True while rendering on the server and hydrating that HTML, where the\n  // inline script has already run. False when mounting on the client.\n  const hydrating = React.useSyncExternalStore(subscribe, () => false, () => true);\n  const [server] = React.useState(hydrating);\n  const [store] = React.useState(createStore);\n\n  const plan: Plan = {\n    once,\n    key: storageKey,\n    min: minDuration,\n    max: maxDuration,\n    skippable,\n    contained,\n    skip: false,\n    images,\n    t: INTRO,\n  };\n  const planRef = React.useRef(plan);\n  const completeRef = React.useRef(onComplete);\n  React.useLayoutEffect(() => {\n    completeRef.current = onComplete;\n  });\n\n  React.useLayoutEffect(() => {\n    const root = ref.current as LoaderRoot | null;\n    if (!root) return;\n    // Server HTML without a handle means the script was blocked, by a Content\n    // Security Policy without this nonce. Skip rather than cover a page\n    // that's already been seen.\n    const handle = root.__introLoader ?? introLoader(root, { ...planRef.current, skip: server });\n    const off = handle.on(() => {\n      store.set();\n      completeRef.current?.();\n    });\n    return () => {\n      off();\n      // Strict Mode runs effects twice on a node that stays in the page.\n      queueMicrotask(() => {\n        if (!root.isConnected) handle.destroy();\n      });\n    };\n  }, [server, store]);\n\n  return (\n    <IntroContext.Provider value={store}>\n      <div\n        ref={ref}\n        suppressHydrationWarning\n        className={cn(contained && \"relative isolate\", className)}\n        {...props}\n      >\n        {server ? (\n          <script\n            nonce={nonce}\n            suppressHydrationWarning\n            dangerouslySetInnerHTML={{\n              __html: `(${introLoader})(document.currentScript.parentElement,${JSON.stringify(plan).replace(/</g, \"\\\\u003c\")})`,\n            }}\n          />\n        ) : null}\n        <div\n          data-intro-loader-cover=\"\"\n          aria-hidden\n          suppressHydrationWarning\n          className={cn(\n            \"@container inset-0 z-[100] hidden overflow-hidden bg-[var(--intro-loader-bg,Canvas)] p-5 select-none in-data-[intro-loader=fade]:block in-data-[intro-loader=out]:block in-data-[intro-loader=play]:block sm:p-8\",\n            contained ? \"absolute\" : \"fixed\",\n            overlayClassName,\n          )}\n        >\n          <div className=\"relative size-full\">\n            {brand ? (\n              <div className=\"absolute top-0 left-0\">\n                <Riser>{brand}</Riser>\n              </div>\n            ) : null}\n            {counter ? (\n              <div\n                className={cn(\n                  \"absolute text-[clamp(4rem,16cqw,10rem)] leading-none font-medium tracking-[-0.06em]\",\n                  CORNERS[counter],\n                  counterClassName,\n                )}\n              >\n                <Riser counter>{label ?? <Digits />}</Riser>\n              </div>\n            ) : null}\n          </div>\n\n          <div className=\"absolute inset-0 grid place-items-center\">\n            {images.length ? (\n              <div\n                data-intro-loader-frame=\"\"\n                suppressHydrationWarning\n                className={cn(\n                  \"relative aspect-[4/5] w-[clamp(7.5rem,18cqw,12rem)] overflow-clip rounded-sm\",\n                  frameClassName,\n                )}\n              >\n                {images.slice(0, -1).map((src, i) => (\n                  <div\n                    key={i}\n                    data-intro-loader-shot=\"\"\n                    suppressHydrationWarning\n                    className=\"absolute inset-0 [clip-path:inset(100%_0_0_0)]\"\n                  >\n                    <Shot src={src} className=\"size-full object-cover\" />\n                  </div>\n                ))}\n              </div>\n            ) : mark != null ? (\n              <Mark mark={mark} className={markClassName} />\n            ) : null}\n          </div>\n\n          {images.length ? (\n            <div\n              data-intro-loader-flier=\"\"\n              suppressHydrationWarning\n              className=\"absolute top-0 left-0 origin-top-left [clip-path:inset(100%_0_0_0)]\"\n            >\n              <Shot src={images[images.length - 1]} className=\"block size-full object-cover\" />\n            </div>\n          ) : null}\n        </div>\n        {children}\n      </div>\n    </IntroContext.Provider>\n  );\n}\n\n/**\n * The loader's images carry their address in data-src. The script fills in\n * src only when it plays, so a visitor who's seen it doesn't download them.\n */\nfunction Shot({ src, className }: { src: string; className?: string }) {\n  return <img data-src={src} alt=\"\" draggable={false} suppressHydrationWarning className={className} />;\n}\n\n/**\n * Rises out of a mask on the first paint. The padding keeps descenders inside\n * the mask; the negative margin takes the room back. It travels well past the\n * mask, so figures that hang outside their line, like old-style ones, clear it.\n */\nfunction Riser({ counter, children }: { counter?: boolean; children: React.ReactNode }) {\n  return (\n    <span className=\"-my-[0.1em] block overflow-y-clip py-[0.1em]\">\n      <span\n        data-intro-loader-in=\"\"\n        data-intro-loader-counter={counter ? \"\" : undefined}\n        suppressHydrationWarning\n        className=\"block [transform:translateY(140%)]\"\n      >\n        {children}\n      </span>\n    </span>\n  );\n}\n\nconst STRIP = Array.from({ length: 20 }, (_, i) => i % 10);\n\n/**\n * Three fixed-width digits, each a strip of 0-9 twice that rolls behind a\n * one-digit mask. The line height is set here, where a size passed in can't\n * merge it away: the strips' steps depend on it.\n */\nfunction Digits() {\n  return (\n    <span className=\"flex leading-[0.8] tabular-nums\">\n      {[0, 1, 2].map((place) => (\n        <span key={place} className=\"block h-[0.8em] overflow-y-clip\">\n          <span data-intro-loader-digit=\"\" suppressHydrationWarning className=\"block\">\n            {STRIP.map((digit, i) => (\n              <span key={i} className=\"block h-[0.8em]\">\n                {digit}\n              </span>\n            ))}\n          </span>\n        </span>\n      ))}\n    </span>\n  );\n}\n\nfunction Mark({ mark, className }: { mark: React.ReactNode; className?: string }) {\n  const word = typeof mark === \"string\" ? mark : null;\n  const glyphs = word === null ? [mark] : Array.from(word);\n  return (\n    <span\n      data-intro-loader-mark=\"\"\n      suppressHydrationWarning\n      className={cn(\n        \"relative flex text-[clamp(4rem,19cqw,14rem)] font-semibold tracking-[-0.045em] whitespace-pre\",\n        className,\n      )}\n    >\n      {glyphs.map((glyph, i) => (\n        <span key={i} suppressHydrationWarning className=\"-my-[0.1em] block overflow-y-clip py-[0.1em] leading-none\">\n          <span data-intro-loader-glyph=\"\" suppressHydrationWarning className=\"block [transform:translateY(115%)]\">\n            {glyph}\n          </span>\n        </span>\n      ))}\n      {/* The word set whole, for where each letter falls once kerned. */}\n      {word !== null ? (\n        <span data-intro-loader-word=\"\" className=\"invisible absolute top-0 left-0 leading-none\">\n          {word}\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\nexport interface IntroLoaderTargetProps extends React.HTMLAttributes<HTMLSpanElement> {\n  /** What the last image lands on: an image, a video, a logo. Make it look like the last image or the mark. */\n  children?: React.ReactNode;\n}\n\n/**\n * Marks where the loader's last image lands. It adds no box of its own, so it\n * can wrap anything, anywhere; the loader measures its first child. It stays\n * hidden until the image lands on it, so it never shows doubled under it.\n */\nexport function IntroLoaderTarget({ className, children, ...props }: IntroLoaderTargetProps) {\n  return (\n    <span\n      data-intro-loader-target=\"\"\n      className={cn(\"contents in-data-[intro-loader=play]:invisible\", className)}\n      {...props}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport interface IntroLoaderLineProps extends React.HTMLAttributes<HTMLSpanElement> {\n  /** Rise from below, like a headline, or drop from above, like a nav. */\n  from?: \"below\" | \"above\";\n  children?: React.ReactNode;\n}\n\n/**\n * A line of the page that rises out of a clipped mask once the loader hands\n * off. Block by default; it can hold any element, not only text.\n */\nexport function IntroLoaderLine({ from = \"below\", className, children, ...props }: IntroLoaderLineProps) {\n  return (\n    <span\n      data-intro-loader-line={from}\n      // The padding keeps descenders and accents inside the mask; the\n      // negative margin takes the room back. It only clips while it plays,\n      // so focus rings and shadows are whole afterwards.\n      className={cn(\n        \"-my-[0.12em] block py-[0.12em] in-data-[intro-loader=play]:overflow-y-clip in-data-[intro-loader=reveal]:overflow-y-clip\",\n        className,\n      )}\n      {...props}\n    >\n      <span\n        className={cn(\n          \"block\",\n          from === \"above\"\n            ? \"in-data-[intro-loader=play]:[transform:translateY(-115%)]\"\n            : \"in-data-[intro-loader=play]:[transform:translateY(115%)]\",\n        )}\n      >\n        {children}\n      </span>\n    </span>\n  );\n}\n"
    }
  ]
}