{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lifeline",
  "title": "Lifeline",
  "description": "The Lifeline component system — horizontal and scroll-scrubbed on desktop, vertical on mobile, floating photo cards, hover media, a tap-to-expand lightbox, and a cinematic intro. Works as a full page or as a module inside one: mode=\"embed\" scrubs the rail with the wheel and hands scrolling back to the page at either end.",
  "dependencies": [
    "lucide-react",
    "next-themes",
    "geist"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/lifeline/company-icon.tsx",
      "content": "import type { ComponentType } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type CompanyIconId = string\n\nexport interface CompanyIconEntry {\n  icon: ComponentType<{ className?: string }>\n  /** Tailwind size for the mark — wordmarks want a wide box. */\n  sizeClassName?: string\n}\n\nconst registry: Record<string, CompanyIconEntry> = {}\n\n/**\n * Map your organization ids to icon components. Call once at module\n * scope, from a module that loads before the timeline renders:\n *\n *   registerCompanyIcons({\n *     acme: { icon: AcmeIcon, sizeClassName: \"h-4 w-4\" },\n *   })\n *\n * Unregistered ids fall back to the name's initial in a small ring,\n * so a timeline reads cleanly before you've drawn a single logo.\n */\nexport function registerCompanyIcons(\n  entries: Record<string, CompanyIconEntry>,\n) {\n  Object.assign(registry, entries)\n}\n\nexport function CompanyIcon({\n  id,\n  label,\n  className,\n}: {\n  id: CompanyIconId\n  label: string\n  className?: string\n}) {\n  const entry = registry[id]\n\n  if (entry) {\n    const Icon = entry.icon\n    return (\n      <span\n        className={cn(\n          \"inline-flex shrink-0 items-center justify-center text-black transition-colors duration-300 dark:text-white\",\n          entry.sizeClassName ?? \"h-4 w-4\",\n          className,\n        )}\n        aria-label={label}\n        title={label}\n      >\n        <Icon className=\"h-full w-full\" />\n      </span>\n    )\n  }\n\n  return (\n    <span\n      title={label}\n      aria-label={label}\n      className={cn(\n        \"inline-flex h-5 w-5 select-none items-center justify-center rounded-full text-[10px] font-semibold uppercase leading-none ring-1 ring-current/30\",\n        className,\n      )}\n    >\n      {label.charAt(0)}\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/lifeline/company-icon.tsx"
    },
    {
      "path": "components/lifeline/index.ts",
      "content": "export { Lifeline } from \"./lifeline\"\nexport { LifelineMarkerColumn } from \"./lifeline-marker\"\nexport { useLifelineScroll } from \"./use-lifeline-scroll\"\nexport { CompanyIcon } from \"./company-icon\"\nexport { LifelineLegend } from \"./lifeline-legend\"\nexport type { CompanyIconId } from \"./company-icon\"\n\nexport type {\n  LifelineCompany,\n  LifelineEvent,\n  LifelineEventSegment,\n  LifelineLegendItem,\n  LifelineMarker,\n  LifelineMetPerson,\n  LifelineMentor,\n  LifelineMode,\n  LifelinePhoto,\n  LifelineProps,\n} from \"./types\"",
      "type": "registry:component",
      "target": "components/lifeline/index.ts"
    },
    {
      "path": "components/lifeline/lifeline-desktop.tsx",
      "content": "\"use client\"\n\nimport { useMemo, type CSSProperties } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  LifelineStickyLabels,\n  LIFELINE_STICKY_SHIELD_WIDTH,\n} from \"./lifeline-labels\"\nimport { LifelineMarkerColumn } from \"./lifeline-marker\"\nimport type { LifelineEventImage, LifelineProps } from \"./types\"\nimport { getLifelineEventImage } from \"./lifeline-event\"\nimport { LifelineHoverImageProvider } from \"./lifeline-hover-image\"\nimport { LifelineFloatingPhotos } from \"./lifeline-photos\"\nimport { useLifelineIntro } from \"./use-lifeline-intro\"\nimport { useLifelineScroll } from \"./use-lifeline-scroll\"\nimport { getMarkerWidth } from \"./lifeline-utils\"\n\nexport function LifelineDesktop({\n  markers,\n  birthYear,\n  className,\n  title = \"Lifeline\",\n  mode = \"auto\",\n}: LifelineProps) {\n  const widths = useMemo(\n    () =>\n      markers.map((marker, index) =>\n        getMarkerWidth(marker, markers[index + 1]?.year),\n      ),\n    [markers],\n  )\n\n  // Left edge of each marker's slot within the track — anchors for the\n  // floating photo cards.\n  const offsets = useMemo(() => {\n    const result: number[] = []\n    let sum = 0\n    for (const width of widths) {\n      result.push(sum)\n      sum += width\n    }\n    return result\n  }, [widths])\n\n  const hoverImages = useMemo(() => {\n    const images: LifelineEventImage[] = []\n    for (const marker of markers) {\n      for (const event of marker.events) {\n        const image = getLifelineEventImage(event)\n        if (image) images.push(image)\n      }\n    }\n    return images\n  }, [markers])\n\n  const intro = useLifelineIntro(widths)\n  const isIntroAnimating = intro.shouldPlay && intro.isPlaying\n\n  const {\n    sectionRef,\n    trackRef,\n    labelsRef,\n    setMarkerRef,\n    isLayoutReady,\n    isEmbed,\n    introArmed,\n  } = useLifelineScroll(markers.length, {\n    mode,\n    introLocked: isIntroAnimating,\n    introAnimating: isIntroAnimating,\n    introSkipped: !intro.shouldPlay,\n    introRailMs: intro.railDuration,\n    introGetTrackProgress: intro.getTrackProgressAtTime,\n    onIntroScrollStart: intro.startIntroTimer,\n    onIntroSettleComplete: intro.completeIntro,\n  })\n\n  // Embedded, the open waits for the module to come into view: the marker\n  // fades are CSS animations that start the moment their class lands, so\n  // applying it early would spend them below the fold.\n  const introWaitingInView = isEmbed && intro.shouldPlay && !introArmed\n  const showIntro = isIntroAnimating && isLayoutReady && !introWaitingInView\n\n  const trackWidth =\n    LIFELINE_STICKY_SHIELD_WIDTH + widths.reduce((sum, width) => sum + width, 0)\n\n  const introStyle = {\n    \"--lifeline-labels-ms\": `${intro.labelsDuration}ms`,\n    \"--lifeline-rail-ms\": `${intro.railDuration}ms`,\n  } as CSSProperties\n\n  return (\n    <section\n      ref={sectionRef}\n      data-lifeline-mode={isEmbed ? \"embed\" : \"page\"}\n      // Embedded, the module needs a tab stop to be operable at all — a\n      // page-mode lifeline is reached just by scrolling to it.\n      tabIndex={isEmbed ? 0 : undefined}\n      className={cn(\n        \"relative h-full min-h-0 select-none overflow-hidden [&_a]:cursor-pointer\",\n        // `pan-y` lets the browser start a vertical page scroll on the\n        // first frame instead of waiting on the JS axis lock; horizontal\n        // panning stays ours.\n        isEmbed &&\n          \"touch-pan-y focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n        // Hold it blank rather than showing a settled timeline that then\n        // resets itself to play the intro. Below the fold there is nothing\n        // to see anyway, and the arming margin means it fills in before it\n        // reaches the reader.\n        (!isLayoutReady || introWaitingInView) && \"invisible\",\n        className,\n      )}\n      aria-label={title}\n      style={showIntro ? introStyle : undefined}\n    >\n      <LifelineHoverImageProvider preload={hoverImages}>\n      {/*\n        Centered — but `safe center` where the browser understands it, which\n        matters once the height is the consumer's to choose. A track taller\n        than its box would otherwise overflow equally top and bottom, and\n        since the section clips, the first thing lost is the row nearest the\n        top: the Age/Years label column and the year labels. `safe` falls\n        back to start-alignment exactly in that case, so the labels and the\n        rail stay put and only the tail of a long column clips. Declared\n        inline so browsers without it simply keep the `items-center` class.\n      */}\n      <div\n        className=\"flex h-full items-center overflow-hidden\"\n        style={isEmbed ? { alignItems: \"safe center\" } : undefined}\n      >\n        <div\n          ref={trackRef}\n          className=\"relative flex w-max items-start will-change-transform [--lifeline-people-top:calc(14.5rem+40px)] [--lifeline-rail:5rem]\"\n          style={{ width: trackWidth }}\n        >\n          {/*\n            LIFELINE_STICKY_SHIELD_WIDTH reserves this column at the head of\n            the track, and the column has to actually paint it: once the\n            track scrolls, marker text passes underneath and would otherwise\n            read straight through \"Age\" and \"Years\".\n\n            `bg-white dark:bg-black` to match the framing the shell puts\n            around this — reframe the page on a different surface and this\n            wants overriding with it. The transition is not decoration\n            either: without it the shield snaps between the two while the\n            page behind it is still crossfading, which flashes a hard box\n            for the length of a theme switch. 300ms on the default curve is\n            what `LifelineShell` fades on, so the two move as one.\n          */}\n          <div\n            ref={labelsRef}\n            className=\"lifeline-labels shrink-0 bg-white transition-colors duration-300 will-change-transform dark:bg-black\"\n            style={{ width: LIFELINE_STICKY_SHIELD_WIDTH }}\n          >\n            <div className={cn(showIntro && \"lifeline-labels-intro\")}>\n              <LifelineStickyLabels />\n            </div>\n          </div>\n\n          <div className=\"relative\">\n            <div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-x-0 top-[var(--lifeline-rail)] h-px overflow-hidden\"\n            >\n              <div\n                className={cn(\n                  \"h-px w-full border-t border-dashed border-zinc-300 transition-colors duration-300 dark:border-zinc-800\",\n                  showIntro && \"lifeline-rail-intro\",\n                )}\n              />\n            </div>\n\n            <div className=\"relative flex items-start\">\n              {markers.map((marker, index) => (\n                <LifelineMarkerColumn\n                  key={marker.id}\n                  ref={(node) => setMarkerRef(index, node)}\n                  marker={marker}\n                  birthYear={birthYear}\n                  minWidth={widths[index]}\n                  animateIntro={showIntro}\n                  introDelay={intro.getMarkerDelay(index)}\n                  introDuration={intro.getMarkerFadeDuration(index)}\n                />\n              ))}\n            </div>\n\n            <LifelineFloatingPhotos\n              markers={markers}\n              offsets={offsets}\n              widths={widths}\n              animateIntro={showIntro}\n              getIntroDelay={intro.getMarkerDelay}\n              getIntroDuration={intro.getMarkerFadeDuration}\n            />\n          </div>\n        </div>\n      </div>\n\n      </LifelineHoverImageProvider>\n    </section>\n  )\n}",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-desktop.tsx"
    },
    {
      "path": "components/lifeline/lifeline-event.tsx",
      "content": "import type {\n  LifelineEvent,\n  LifelineEventEffect,\n  LifelineEventImage,\n  LifelineEventSegment,\n} from \"./types\"\n\nfunction getEventContent(\n  event: LifelineEvent,\n): string | LifelineEventSegment[] {\n  if (typeof event === \"object\" && !Array.isArray(event) && \"text\" in event) {\n    return event.text\n  }\n\n  return event\n}\n\nexport function getLifelineEventImage(\n  event: LifelineEvent,\n): LifelineEventImage | undefined {\n  if (typeof event === \"object\" && !Array.isArray(event) && \"image\" in event) {\n    return event.image\n  }\n\n  return undefined\n}\n\nexport function getLifelineEventEffect(\n  event: LifelineEvent,\n): LifelineEventEffect | undefined {\n  if (typeof event === \"object\" && !Array.isArray(event) && \"effect\" in event) {\n    return event.effect\n  }\n\n  return undefined\n}\n\nexport function LifelineEventText({\n  event,\n  className,\n}: {\n  event: LifelineEvent\n  className?: string\n}) {\n  const content = getEventContent(event)\n\n  if (typeof content === \"string\") {\n    return <span className={className}>{content}</span>\n  }\n\n  return (\n    <span className={className}>\n      {content.map((segment, index) =>\n        segment.type === \"link\" ? (\n          <a\n            key={index}\n            href={segment.href}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"underline decoration-zinc-400 underline-offset-2 transition-colors duration-300 group-hover:text-black group-hover:decoration-zinc-600 dark:decoration-zinc-700 dark:group-hover:text-white dark:group-hover:decoration-zinc-400\"\n          >\n            {segment.value}\n          </a>\n        ) : (\n          <span key={index}>{segment.value}</span>\n        ),\n      )}\n    </span>\n  )\n}\n\n/** Always-visible media embedded in the timeline (image.inline). */\nexport function LifelineEventMedia({\n  media,\n  className,\n}: {\n  media: LifelineEventImage\n  className?: string\n}) {\n  if (media.video) {\n    return (\n      <video\n        src={media.video}\n        poster={media.src}\n        autoPlay\n        muted\n        loop\n        playsInline\n        preload=\"metadata\"\n        aria-label={media.alt}\n        className={className}\n      />\n    )\n  }\n\n  return (\n    // eslint-disable-next-line @next/next/no-img-element\n    <img src={media.src} alt={media.alt} loading=\"lazy\" className={className} />\n  )\n}\n\nexport function getLifelineEventKey(event: LifelineEvent, index: number) {\n  const content = getEventContent(event)\n\n  if (typeof content === \"string\") return `${index}-${content}`\n\n  return `${index}-${content.map((segment) => segment.value).join(\"\")}`\n}\n",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-event.tsx"
    },
    {
      "path": "components/lifeline/lifeline-fireworks.tsx",
      "content": "\"use client\"\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\"\nimport { useTheme } from \"next-themes\"\nimport type { LifelineEventEffect } from \"./types\"\n\n/** Tweak these */\nconst DURATION_S = 7.5\nconst MAX_DPR = 1.5\n/** Wait for the theme cross-fade before the first burst. */\nconst NIGHTFALL_MS = 400\n\ntype Palette = [number[], number[], number[]]\n\nconst PALETTES: Record<LifelineEventEffect, Palette> = {\n  // Old Glory red / white / blue\n  fireworks: [\n    [0.9, 0.15, 0.25],\n    [1.0, 1.0, 1.0],\n    [0.25, 0.45, 0.95],\n  ],\n  // celeste / white / pale celeste\n  \"fireworks-argentina\": [\n    [0.45, 0.75, 0.98],\n    [1.0, 1.0, 1.0],\n    [0.7, 0.87, 1.0],\n  ],\n}\n\nconst VERTEX_SHADER = `\nattribute vec2 a_pos;\nvoid main() {\n  gl_Position = vec4(a_pos, 0.0, 1.0);\n}\n`\n\n/**\n * Additive point-glow fireworks in Old Glory red, white, and blue,\n * composited over a night-sky scrim via premultiplied canvas alpha.\n */\nconst FRAGMENT_SHADER = `\nprecision highp float;\n\nuniform vec2 u_res;\nuniform float u_time;\nuniform float u_dur;\nuniform vec3 u_c0;\nuniform vec3 u_c1;\nuniform vec3 u_c2;\n\n#define TAU 6.28318530718\n#define N_FIREWORKS 10\n#define N_PARTICLES 42\n\nfloat hash(float n) {\n  return fract(sin(n) * 43758.5453123);\n}\n\nvec3 palette(float m) {\n  if (m < 0.5) return u_c0;\n  if (m < 1.5) return u_c1;\n  return u_c2;\n}\n\nvoid main() {\n  vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;\n  float t = u_time;\n  float env = smoothstep(0.0, 0.5, t) * (1.0 - smoothstep(u_dur - 1.0, u_dur, t));\n\n  vec3 col = vec3(0.0);\n\n  for (int i = 0; i < N_FIREWORKS; i++) {\n    float fi = float(i);\n    float t0 = 0.35 + fi * (u_dur - 2.8) / float(N_FIREWORKS) + hash(fi * 7.31) * 0.3;\n    // No flow control in the loop — some WebGL1 driver translations\n    // mishandle continue, so inactive bursts multiply to zero instead.\n    float active = step(t0, t) * step(t, t0 + 1.8);\n    float lt = clamp((t - t0) / 1.8, 0.0, 1.0);\n\n    vec2 center = vec2((hash(fi * 3.7) - 0.5) * 1.6, -0.05 + hash(fi * 9.1) * 0.5);\n    vec3 base = palette(mod(fi, 3.0)); // strict red / white / blue rotation\n    // Ramp in fast so overlapping particles at ignition don't stack\n    // into a blown-out ball, then decay.\n    float fade = exp(-lt * 4.0) * min(1.0, lt * 6.0);\n\n    for (int j = 0; j < N_PARTICLES; j++) {\n      float fj = float(j);\n      float angle = (fj / float(N_PARTICLES)) * TAU + hash(fi * 100.0 + fj) * 0.15;\n      float speed = 0.16 + 0.22 * hash(fj * 7.77 + fi * 31.3);\n\n      vec2 p = center + vec2(cos(angle), sin(angle)) * speed * sqrt(lt);\n      p.y -= 0.09 * lt * lt; // gravity\n\n      float d = max(length(uv - p), 0.004);\n      float sparkle = 0.7 + 0.3 * sin(30.0 * lt + fj * 1.7);\n      col += base * active * fade * sparkle * 0.0006 / (d * d);\n    }\n  }\n\n  col = clamp(col * env, 0.0, 1.0);\n  float spark = max(col.r, max(col.g, col.b));\n  float scrim = 0.45 * env;\n  gl_FragColor = vec4(col, max(spark, scrim));\n}\n`\n\ninterface LifelineFireworksApi {\n  launch: (effect: LifelineEventEffect) => void\n}\n\nconst LifelineFireworksContext = createContext<LifelineFireworksApi | null>(\n  null,\n)\n\nexport function useLifelineFireworks() {\n  return useContext(LifelineFireworksContext)\n}\n\nfunction FireworksCanvas({\n  palette,\n  onDone,\n}: {\n  palette: Palette\n  onDone: () => void\n}) {\n  const paletteRef = useRef(palette)\n  paletteRef.current = palette\n  const canvasRef = useRef<HTMLCanvasElement>(null)\n  const onDoneRef = useRef(onDone)\n  onDoneRef.current = onDone\n\n  useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n\n    const gl = canvas.getContext(\"webgl\", {\n      alpha: true,\n      premultipliedAlpha: true,\n      antialias: false,\n    })\n    if (!gl) {\n      onDoneRef.current()\n      return\n    }\n\n    const compile = (type: number, source: string) => {\n      const shader = gl.createShader(type)!\n      gl.shaderSource(shader, source)\n      gl.compileShader(shader)\n      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n        console.warn(\"fireworks shader:\", gl.getShaderInfoLog(shader))\n      }\n      return shader\n    }\n\n    const program = gl.createProgram()!\n    gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER))\n    gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER))\n    gl.linkProgram(program)\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n      console.warn(\"fireworks link:\", gl.getProgramInfoLog(program))\n      onDoneRef.current()\n      return\n    }\n    gl.useProgram(program)\n\n    const buffer = gl.createBuffer()\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n    gl.bufferData(\n      gl.ARRAY_BUFFER,\n      new Float32Array([-1, -1, 3, -1, -1, 3]),\n      gl.STATIC_DRAW,\n    )\n    const aPos = gl.getAttribLocation(program, \"a_pos\")\n    gl.enableVertexAttribArray(aPos)\n    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)\n\n    const uRes = gl.getUniformLocation(program, \"u_res\")\n    const uTime = gl.getUniformLocation(program, \"u_time\")\n    const uDur = gl.getUniformLocation(program, \"u_dur\")\n\n    const [c0, c1, c2] = paletteRef.current\n    gl.uniform3f(gl.getUniformLocation(program, \"u_c0\"), c0[0], c0[1], c0[2])\n    gl.uniform3f(gl.getUniformLocation(program, \"u_c1\"), c1[0], c1[1], c1[2])\n    gl.uniform3f(gl.getUniformLocation(program, \"u_c2\"), c2[0], c2[1], c2[2])\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)\n      canvas.width = Math.round(window.innerWidth * dpr)\n      canvas.height = Math.round(window.innerHeight * dpr)\n      gl.viewport(0, 0, canvas.width, canvas.height)\n    }\n    resize()\n    window.addEventListener(\"resize\", resize)\n\n    let frame = 0\n    const start = performance.now()\n\n    const step = (now: number) => {\n      const t = (now - start) / 1000\n      if (t >= DURATION_S) {\n        onDoneRef.current()\n        return\n      }\n\n      gl.uniform2f(uRes, canvas.width, canvas.height)\n      gl.uniform1f(uTime, t)\n      gl.uniform1f(uDur, DURATION_S)\n      gl.clearColor(0, 0, 0, 0)\n      gl.clear(gl.COLOR_BUFFER_BIT)\n      gl.drawArrays(gl.TRIANGLES, 0, 3)\n\n      frame = requestAnimationFrame(step)\n    }\n    frame = requestAnimationFrame(step)\n\n    return () => {\n      cancelAnimationFrame(frame)\n      window.removeEventListener(\"resize\", resize)\n      // No manual loseContext: StrictMode re-runs this effect on the\n      // same canvas, and a lost context can never compile again.\n    }\n  }, [])\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden=\"true\"\n      className=\"pointer-events-none fixed inset-0 z-[70] h-full w-full\"\n    />\n  )\n}\n\nexport function LifelineFireworksProvider({\n  children,\n}: {\n  children: ReactNode\n}) {\n  const [playing, setPlaying] = useState(false)\n  const [effect, setEffect] = useState<LifelineEventEffect>(\"fireworks\")\n  const { resolvedTheme, setTheme } = useTheme()\n  const restoreThemeRef = useRef<string | null>(null)\n  const nightfallRef = useRef(0)\n  const playingRef = useRef(false)\n  playingRef.current = playing\n\n  useEffect(() => {\n    return () => window.clearTimeout(nightfallRef.current)\n  }, [])\n\n  const launch = useCallback((nextEffect: LifelineEventEffect) => {\n    if (playingRef.current) return\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return\n\n    setEffect(nextEffect)\n\n    // Fireworks belong in the dark: switch a light page to dark for\n    // the show, and restore afterwards.\n    if (resolvedTheme === \"light\") {\n      restoreThemeRef.current = \"light\"\n      setTheme(\"dark\")\n      window.clearTimeout(nightfallRef.current)\n      nightfallRef.current = window.setTimeout(\n        () => setPlaying(true),\n        NIGHTFALL_MS,\n      )\n      return\n    }\n\n    restoreThemeRef.current = null\n    setPlaying(true)\n  }, [resolvedTheme, setTheme])\n\n  const done = useCallback(() => {\n    setPlaying(false)\n    if (restoreThemeRef.current) {\n      setTheme(restoreThemeRef.current)\n      restoreThemeRef.current = null\n    }\n  }, [setTheme])\n\n  return (\n    <LifelineFireworksContext.Provider value={{ launch }}>\n      {children}\n      {playing && <FireworksCanvas palette={PALETTES[effect]} onDone={done} />}\n    </LifelineFireworksContext.Provider>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-fireworks.tsx"
    },
    {
      "path": "components/lifeline/lifeline-hover-image.tsx",
      "content": "\"use client\"\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  type ReactNode,\n} from \"react\"\nimport { clamp } from \"./lifeline-utils\"\nimport type { LifelineEventImage } from \"./types\"\n\n/** Tweak these */\nconst FOLLOW_EASE = 0.16\nconst TILT_FACTOR = 0.14\nconst TILT_MAX_DEG = 7\nconst TILT_EASE = 0.1\nconst CURSOR_OFFSET_X = 24\nconst CURSOR_OFFSET_Y = 96\n\ninterface LifelineHoverImageApi {\n  show: (image: LifelineEventImage) => void\n  hide: () => void\n}\n\ninterface LifelineHoverImageProviderProps {\n  children: ReactNode\n  /** Warmed into the browser cache during idle so swaps are instant. */\n  preload?: LifelineEventImage[]\n}\n\nconst LifelineHoverImageContext = createContext<LifelineHoverImageApi | null>(\n  null,\n)\n\nexport function useLifelineHoverImage() {\n  return useContext(LifelineHoverImageContext)\n}\n\n/**\n * A cursor-following image reveal. The floating element lives at the\n * provider level — it must stay outside the transformed track, since\n * position: fixed resolves against the nearest transformed ancestor.\n */\nexport function LifelineHoverImageProvider({\n  children,\n  preload,\n}: LifelineHoverImageProviderProps) {\n  const containerRef = useRef<HTMLDivElement>(null)\n  const imageRef = useRef<HTMLImageElement>(null)\n  const videoRef = useRef<HTMLVideoElement>(null)\n  const state = useRef({\n    x: 0,\n    y: 0,\n    targetX: 0,\n    targetY: 0,\n    tilt: 0,\n    visible: false,\n    hoverCapable: false,\n    frame: 0,\n  })\n\n  useEffect(() => {\n    state.current.hoverCapable = window.matchMedia(\n      \"(hover: hover) and (pointer: fine)\",\n    ).matches\n\n    const onMouseMove = (event: MouseEvent) => {\n      state.current.targetX = event.clientX\n      state.current.targetY = event.clientY\n    }\n\n    window.addEventListener(\"mousemove\", onMouseMove, { passive: true })\n    return () => {\n      window.removeEventListener(\"mousemove\", onMouseMove)\n      cancelAnimationFrame(state.current.frame)\n    }\n  }, [])\n\n  useEffect(() => {\n    if (!preload?.length || !state.current.hoverCapable) return\n\n    const warm = () => {\n      preload.forEach(({ src }) => {\n        const image = new window.Image()\n        image.src = src\n      })\n    }\n\n    if (typeof window.requestIdleCallback === \"function\") {\n      const handle = window.requestIdleCallback(warm)\n      return () => window.cancelIdleCallback(handle)\n    }\n\n    const timeout = window.setTimeout(warm, 2000)\n    return () => window.clearTimeout(timeout)\n  }, [preload])\n\n  const step = useCallback(() => {\n    const s = state.current\n    const container = containerRef.current\n    if (!container) return\n\n    const dx = s.targetX - s.x\n    s.x += dx * FOLLOW_EASE\n    s.y += (s.targetY - s.y) * FOLLOW_EASE\n\n    const targetTilt = clamp(dx * TILT_FACTOR, -TILT_MAX_DEG, TILT_MAX_DEG)\n    s.tilt += (targetTilt - s.tilt) * TILT_EASE\n\n    container.style.transform = `translate3d(${s.x + CURSOR_OFFSET_X}px, ${\n      s.y - CURSOR_OFFSET_Y\n    }px, 0) rotate(${s.tilt}deg)`\n\n    if (s.visible) {\n      s.frame = requestAnimationFrame(step)\n    }\n  }, [])\n\n  const api = useMemo<LifelineHoverImageApi>(\n    () => ({\n      show(image) {\n        const s = state.current\n        const container = containerRef.current\n        const img = imageRef.current\n        const video = videoRef.current\n        if (!s.hoverCapable || !container || !img) return\n\n        if (image.video && video) {\n          // Video takes over the card; the image element sits this one out.\n          img.style.display = \"none\"\n          video.style.display = \"block\"\n\n          const targetVideo = new URL(image.video, window.location.origin).href\n          if (video.src !== targetVideo) {\n            video.src = targetVideo\n            video.poster = image.src\n          }\n          video.play().catch(() => {\n            // Autoplay rejection just leaves the poster frame showing.\n          })\n\n          if (!s.visible) {\n            s.x = s.targetX\n            s.y = s.targetY\n            s.tilt = 0\n          }\n\n          s.visible = true\n          container.style.opacity = \"1\"\n          video.style.transform = \"scale(1)\"\n\n          cancelAnimationFrame(s.frame)\n          s.frame = requestAnimationFrame(step)\n          return\n        }\n\n        if (video) {\n          video.pause()\n          video.style.display = \"none\"\n        }\n        img.style.display = \"block\"\n\n        const targetSrc = new URL(image.src, window.location.origin).href\n\n        if (img.src !== targetSrc) {\n          // Kill the previous bitmap instantly — the browser would keep\n          // showing it until the new file decodes.\n          img.style.visibility = \"hidden\"\n          img.src = targetSrc\n          img.alt = image.alt\n\n          const reveal = () => {\n            // Only reveal if this image is still the requested one.\n            if (img.src === targetSrc) {\n              img.style.visibility = \"visible\"\n            }\n          }\n\n          if (img.complete) {\n            reveal()\n          } else {\n            img.decode().then(reveal, reveal)\n          }\n        } else {\n          img.style.visibility = \"visible\"\n        }\n\n        if (!s.visible) {\n          // Materialize at the cursor instead of flying in from the\n          // last resting point.\n          s.x = s.targetX\n          s.y = s.targetY\n          s.tilt = 0\n        }\n\n        s.visible = true\n        container.style.opacity = \"1\"\n        img.style.transform = \"scale(1)\"\n\n        cancelAnimationFrame(s.frame)\n        s.frame = requestAnimationFrame(step)\n      },\n      hide() {\n        const s = state.current\n        const container = containerRef.current\n        const img = imageRef.current\n        const video = videoRef.current\n        if (!container || !img) return\n\n        s.visible = false\n        container.style.opacity = \"0\"\n        img.style.transform = \"scale(0.94)\"\n        if (video) {\n          video.pause()\n          video.style.transform = \"scale(0.94)\"\n        }\n      },\n    }),\n    [step],\n  )\n\n  return (\n    <LifelineHoverImageContext.Provider value={api}>\n      {children}\n      <div\n        ref={containerRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none fixed left-0 top-0 z-[60] opacity-0 transition-opacity duration-200 ease-out will-change-transform\"\n      >\n        {/* eslint-disable-next-line @next/next/no-img-element */}\n        <img\n          ref={imageRef}\n          alt=\"\"\n          className=\"w-[280px] scale-95 rounded-xl shadow-2xl ring-1 ring-black/10 transition-[transform,box-shadow] duration-200 ease-out dark:ring-white/15\"\n          decoding=\"async\"\n        />\n        <video\n          ref={videoRef}\n          muted\n          loop\n          playsInline\n          preload=\"none\"\n          // Landscape fills the same 280px card as images; portrait is\n          // capped by height instead, matching the floating cards'\n          // scale (180x320) so tall clips don't tower over the cursor.\n          className=\"max-h-[320px] w-auto max-w-[280px] scale-95 rounded-xl shadow-2xl ring-1 ring-black/10 transition-[transform,box-shadow] duration-200 ease-out dark:ring-white/15\"\n          style={{ display: \"none\" }}\n        />\n      </div>\n    </LifelineHoverImageContext.Provider>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-hover-image.tsx"
    },
    {
      "path": "components/lifeline/lifeline-intro-timing.ts",
      "content": "import { clamp } from \"./lifeline-utils\"\n\n/** Tweak these */\nexport const LIFELINE_SLOW_YEARS = 5\nexport const LIFELINE_SLOW_TIME_RATIO = 0.38\nexport const LIFELINE_SLOW_MARKER_FADE_MS = 720\nexport const LIFELINE_FAST_MARKER_FADE_MS = 280\n/** Set > 0 to override auto-calibrated ease power */\nexport const LIFELINE_EASE_POWER = 0\n\nfunction smoothstep(value: number) {\n  const t = clamp(value, 0, 1)\n  return t * t * (3 - 2 * t)\n}\n\nexport function getSlowTrackWidth(widths: number[]) {\n  return widths\n    .slice(0, LIFELINE_SLOW_YEARS)\n    .reduce((sum, width) => sum + width, 0)\n}\n\nexport function getSlowTrackPortion(widths: number[]) {\n  const total = widths.reduce((sum, width) => sum + width, 0)\n  if (total <= 0) return 0\n  return getSlowTrackWidth(widths) / total\n}\n\nfunction getEasePower(widths: number[]) {\n  if (LIFELINE_EASE_POWER > 0) return LIFELINE_EASE_POWER\n\n  const slowPortion = getSlowTrackPortion(widths)\n  const softenedPivot = smoothstep(LIFELINE_SLOW_TIME_RATIO)\n\n  if (slowPortion <= 0 || softenedPivot <= 0 || softenedPivot >= 1) {\n    return 2.6\n  }\n\n  return Math.log(slowPortion) / Math.log(softenedPivot)\n}\n\nfunction softenedTime(elapsedMs: number, railMs: number) {\n  return smoothstep(elapsedMs / railMs)\n}\n\nexport function trackProgressAtTime(\n  elapsedMs: number,\n  widths: number[],\n  railMs: number,\n) {\n  if (railMs <= 0) return 0\n\n  const t = softenedTime(elapsedMs, railMs)\n  const power = getEasePower(widths)\n\n  return clamp(Math.pow(t, power), 0, 1)\n}\n\nexport function timeAtTrackProgress(\n  progress: number,\n  widths: number[],\n  railMs: number,\n) {\n  const clamped = clamp(progress, 0, 1)\n  if (clamped <= 0) return 0\n  if (clamped >= 1) return railMs\n\n  const power = getEasePower(widths)\n  const softened = Math.pow(clamped, 1 / power)\n\n  return invertSmoothstep(softened) * railMs\n}\n\nfunction invertSmoothstep(value: number) {\n  const target = clamp(value, 0, 1)\n  let lo = 0\n  let hi = 1\n\n  for (let i = 0; i < 20; i++) {\n    const mid = (lo + hi) / 2\n    if (smoothstep(mid) < target) lo = mid\n    else hi = mid\n  }\n\n  return (lo + hi) / 2\n}\n\nexport function isSlowIntroYear(index: number) {\n  return index < LIFELINE_SLOW_YEARS\n}\n\nexport function getTransitionMarkerFadeDuration(index: number) {\n  if (index < LIFELINE_SLOW_YEARS) return LIFELINE_SLOW_MARKER_FADE_MS\n\n  const rampYears = 3\n  const rampIndex = index - LIFELINE_SLOW_YEARS\n  if (rampIndex >= rampYears) return LIFELINE_FAST_MARKER_FADE_MS\n\n  const t = (rampIndex + 1) / rampYears\n  const blend = smoothstep(t)\n\n  return Math.round(\n    LIFELINE_SLOW_MARKER_FADE_MS +\n      (LIFELINE_FAST_MARKER_FADE_MS - LIFELINE_SLOW_MARKER_FADE_MS) * blend,\n  )\n}",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-intro-timing.ts"
    },
    {
      "path": "components/lifeline/lifeline-labels.tsx",
      "content": "export const LIFELINE_LABEL_COLUMN_WIDTH = 56\nexport const LIFELINE_LABEL_GAP = 16\nexport const LIFELINE_STICKY_SHIELD_WIDTH =\n  LIFELINE_LABEL_COLUMN_WIDTH + LIFELINE_LABEL_GAP\nexport const LIFELINE_STICKY_LEFT = 20\n\nexport function LifelineStickyLabels() {\n  return (\n    <div\n      className=\"relative\"\n      style={{ width: LIFELINE_LABEL_COLUMN_WIDTH }}\n      aria-hidden=\"true\"\n    >\n      <div className=\"flex flex-col items-start text-left\">\n        <p className=\"mb-5 h-4 text-[11px] font-medium uppercase leading-4 tracking-[0.08em] text-zinc-500 transition-colors duration-300 dark:text-zinc-600\">\n          Age\n        </p>\n        <p className=\"mb-6 h-5 text-[11px] font-medium uppercase leading-5 tracking-[0.08em] text-zinc-500 transition-colors duration-300 dark:text-zinc-600\">\n          Years\n        </p>\n      </div>\n    </div>\n  )\n}",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-labels.tsx"
    },
    {
      "path": "components/lifeline/lifeline-layout.ts",
      "content": "export const LIFELINE_MOBILE_BREAKPOINT = 768",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-layout.ts"
    },
    {
      "path": "components/lifeline/lifeline-legend.tsx",
      "content": "import type { LifelineLegendItem } from \"./types\"\n\nconst LEGEND_DOT_CLASS: Record<LifelineLegendItem[\"type\"], string> = {\n  mentor: \"bg-blue-500\",\n  met: \"bg-pink-500\",\n}\n\nconst DEFAULT_ITEMS: LifelineLegendItem[] = [\n  { type: \"mentor\", label: \"Mentors\" },\n  { type: \"met\", label: \"Met in person\" },\n]\n\nexport function LifelineLegend({\n  items = DEFAULT_ITEMS,\n}: {\n  items?: LifelineLegendItem[]\n}) {\n  return (\n    <ul className=\"flex flex-wrap items-center gap-x-6 gap-y-2 text-[13px] text-zinc-500\">\n      {items.map((item) => (\n        <li key={item.type} className=\"flex items-center gap-2\">\n          <span\n            className={`h-1.5 w-1.5 shrink-0 rounded-full ${LEGEND_DOT_CLASS[item.type]}`}\n            aria-hidden=\"true\"\n          />\n          <span>{item.label}</span>\n        </li>\n      ))}\n    </ul>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-legend.tsx"
    },
    {
      "path": "components/lifeline/lifeline-lightbox.tsx",
      "content": "\"use client\"\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { cn } from \"@/lib/utils\"\nimport type { LifelinePhoto } from \"./types\"\n\nconst OPEN_MS = 520\n/**\n * Gentle start, soft landing — the quint curve front-loaded nearly all\n * of the travel into the first 120ms, which read as a jump.\n */\nconst EASE = \"cubic-bezier(0.32, 0.72, 0, 1)\"\n/** Fraction of the viewport the expanded media may occupy. */\nconst FIT = 0.85\n\ninterface Target {\n  left: number\n  top: number\n  width: number\n  height: number\n}\n\n/**\n * The card's true geometry at handoff time. Center comes from the\n * bounding box (rotation about center preserves it); width/height are\n * the untransformed layout size (offsetWidth/offsetHeight) — the\n * bounding box of a tilted card is its axis-aligned hull, which is\n * larger than the card and lands the clone visibly off.\n */\nexport interface LifelineLightboxStart {\n  cx: number\n  cy: number\n  w: number\n  h: number\n  /** Playback position of the card's video, for a seamless swap. */\n  mediaTime?: number\n}\n\n/**\n * The clone's media. Videos mount paused, pre-seeked to the card's\n * playback position — a static frame the compositor can scale without\n * decoding — and only play once `playing` flips after the open\n * transition settles. Dismissing pauses again for the return flight.\n */\nfunction LightboxMedia({\n  photo,\n  playing,\n  mediaTime,\n}: {\n  photo: LifelinePhoto\n  playing: boolean\n  mediaTime?: number\n}) {\n  const videoRef = useRef<HTMLVideoElement>(null)\n  const seeded = useRef(false)\n\n  useEffect(() => {\n    const video = videoRef.current\n    if (!video) return\n    if (playing) {\n      // Seek only now, stationary — a seek during the transition\n      // decodes a new frame mid-flight and visibly swaps the image.\n      if (!seeded.current) {\n        seeded.current = true\n        if (mediaTime !== undefined) video.currentTime = mediaTime\n      }\n      video.play().catch(() => {\n        // Autoplay rejection just leaves the poster frame showing.\n      })\n    } else {\n      video.pause()\n    }\n  }, [playing, mediaTime])\n\n  if (!photo.video) {\n    return (\n      // eslint-disable-next-line @next/next/no-img-element\n      <img\n        src={photo.src}\n        alt={photo.alt}\n        className=\"block h-full w-full object-cover\"\n      />\n    )\n  }\n\n  return (\n    <video\n      ref={videoRef}\n      src={photo.video}\n      poster={photo.src}\n      muted\n      loop\n      playsInline\n      preload=\"auto\"\n      aria-label={photo.alt}\n      className=\"block h-full w-full object-cover\"\n    />\n  )\n}\n\nfunction computeTarget(start: LifelineLightboxStart): Target {\n  const vw = window.innerWidth\n  const vh = window.innerHeight\n  const aspect = start.h / start.w\n  const width = Math.min(vw * FIT, (vh * FIT) / aspect)\n  const height = width * aspect\n  return { left: (vw - width) / 2, top: (vh - height) / 2, width, height }\n}\n\n/**\n * Expands a floating card's media from its spot on the timeline to the\n * center of the screen and back — a FLIP animation on a fixed clone\n * portaled to <body> (the track is transformed, so fixed positioning\n * inside it would break). The original card stays in layout, hidden,\n * and is re-measured on dismiss so the media returns wherever the card\n * now is, even if the timeline moved.\n */\nexport function LifelineLightbox({\n  photo,\n  rotate,\n  start,\n  getHome,\n  onClosed,\n}: {\n  photo: LifelinePhoto\n  /** The card's resting tilt — animated away as the media centers. */\n  rotate: number\n  /** The card's geometry at click time. */\n  start: LifelineLightboxStart\n  /** Re-measures the card at dismiss time. */\n  getHome: () => LifelineLightboxStart | null\n  onClosed: () => void\n}) {\n  const target = useRef<Target | null>(null)\n  if (target.current === null) target.current = computeTarget(start)\n  const { left, top, width, height } = target.current\n\n  const figureRef = useRef<HTMLElement>(null)\n\n  const reduceMotion = useRef(\n    typeof window !== \"undefined\" &&\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n  ).current\n\n  // Center-anchored FLIP: rotation and scale about the center match\n  // how the card itself is transformed, so the first frame is\n  // pixel-identical to the card underneath.\n  const toTransform = useCallback(\n    (home: LifelineLightboxStart) =>\n      `translate(${home.cx - (left + width / 2)}px, ${\n        home.cy - (top + height / 2)\n      }px) scale(${home.w / width}) rotate(${rotate}deg)`,\n    [left, top, width, height, rotate],\n  )\n\n  const [entered, setEntered] = useState(reduceMotion)\n  const [transform, setTransform] = useState(() =>\n    reduceMotion ? \"none\" : toTransform(start),\n  )\n  const closing = useRef(false)\n  // Playback waits for the open transition to finish — a playing\n  // video decodes frames while the transform animates and drops\n  // transition frames on mobile; a paused, pre-seeked frame is a\n  // static layer and animates cheaply.\n  const [settled, setSettled] = useState(reduceMotion)\n\n  // Safety net if transitionend never fires for the open.\n  useEffect(() => {\n    if (reduceMotion) return\n    const timeout = window.setTimeout(() => {\n      if (!closing.current) setSettled(true)\n    }, OPEN_MS + 80)\n    return () => window.clearTimeout(timeout)\n  }, [reduceMotion])\n\n  // FLIP: first paint sits over the card, next frame eases to center.\n  useLayoutEffect(() => {\n    if (reduceMotion) return\n    let inner = 0\n    const outer = requestAnimationFrame(() => {\n      inner = requestAnimationFrame(() => {\n        setEntered(true)\n        setTransform(\"translate(0px, 0px) scale(1) rotate(0deg)\")\n      })\n    })\n    return () => {\n      cancelAnimationFrame(outer)\n      cancelAnimationFrame(inner)\n    }\n  }, [reduceMotion])\n\n  // No gesture may pan while the lightbox is up — iOS otherwise\n  // rubber-bands the body behind the fixed overlay and can leave the\n  // whole page stuck offset after dismiss. React's root touch\n  // listeners are passive, so this needs a native non-passive one.\n  const rootRef = useRef<HTMLDivElement>(null)\n  useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n    const block = (event: TouchEvent) => event.preventDefault()\n    root.addEventListener(\"touchmove\", block, { passive: false })\n    return () => root.removeEventListener(\"touchmove\", block)\n  }, [])\n\n  // The press that opens the card dispatches one more click right\n  // after pointerup — by then the clone is mounted underneath the\n  // pointer and would dismiss itself. Swallow exactly that click;\n  // every later click dismisses normally.\n  useEffect(() => {\n    const swallow = (event: MouseEvent) => {\n      event.stopPropagation()\n      event.preventDefault()\n    }\n    window.addEventListener(\"click\", swallow, { capture: true, once: true })\n    const timeout = window.setTimeout(() => {\n      window.removeEventListener(\"click\", swallow, { capture: true })\n    }, 500)\n    return () => {\n      window.clearTimeout(timeout)\n      window.removeEventListener(\"click\", swallow, { capture: true })\n    }\n  }, [])\n\n  const dismiss = useCallback(() => {\n    if (closing.current) return\n    closing.current = true\n    if (reduceMotion) {\n      onClosed()\n      return\n    }\n    setSettled(false) // freeze the video so the return flight is cheap\n    setEntered(false)\n    setTransform(toTransform(getHome() ?? start))\n    // transitionend is the primary signal; this is the safety net.\n    window.setTimeout(onClosed, OPEN_MS + 120)\n  }, [reduceMotion, onClosed, toTransform, getHome, start])\n\n  useEffect(() => {\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") dismiss()\n    }\n    window.addEventListener(\"keydown\", onKeyDown)\n    return () => window.removeEventListener(\"keydown\", onKeyDown)\n  }, [dismiss])\n\n  return createPortal(\n    <div\n      ref={rootRef}\n      className=\"fixed inset-0 z-[999] touch-none overscroll-contain\"\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-label={photo.alt}\n      // The portal lives under <body>, but React events still bubble up\n      // the component tree — without this, a backdrop press would reach\n      // the card's drag handlers and the track's scrubber.\n      onPointerDown={(event) => event.stopPropagation()}\n      onPointerMove={(event) => event.stopPropagation()}\n      onPointerUp={(event) => event.stopPropagation()}\n      onClick={(event) => event.stopPropagation()}\n    >\n      <div\n        className={cn(\n          \"absolute inset-0 cursor-zoom-out bg-black/70 transition-opacity\",\n          entered ? \"opacity-100\" : \"opacity-0\",\n        )}\n        // Synced to the media's travel — a faster fade left the clone\n        // flying over an undimmed page at the end of the dismiss.\n        style={{ transitionDuration: `${OPEN_MS}ms` }}\n        onClick={dismiss}\n      />\n      <figure\n        ref={figureRef}\n        className=\"absolute cursor-zoom-out overflow-hidden rounded-xl shadow-2xl ring-1 ring-black/10 dark:ring-white/15\"\n        style={{\n          left,\n          top,\n          width,\n          height,\n          transform,\n          transformOrigin: \"center\",\n          transition: reduceMotion ? undefined : `transform ${OPEN_MS}ms ${EASE}`,\n          // Promoted for the whole flight — mobile otherwise\n          // re-rasterizes the shadowed, corner-clipped media mid-scale.\n          willChange: \"transform\",\n        }}\n        onClick={dismiss}\n        onTransitionEnd={(event) => {\n          if (event.propertyName !== \"transform\") return\n          if (closing.current) onClosed()\n          else setSettled(true)\n        }}\n      >\n        <LightboxMedia\n          photo={photo}\n          playing={settled}\n          mediaTime={start.mediaTime}\n        />\n      </figure>\n    </div>,\n    document.body,\n  )\n}\n",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-lightbox.tsx"
    },
    {
      "path": "components/lifeline/lifeline-marker.tsx",
      "content": "import { forwardRef, type CSSProperties } from \"react\"\nimport { Film, Image as ImageIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { CompanyIcon } from \"./company-icon\"\nimport {\n  getLifelineEventEffect,\n  getLifelineEventImage,\n  getLifelineEventKey,\n  LifelineEventText,\n} from \"./lifeline-event\"\nimport { useLifelineFireworks } from \"./lifeline-fireworks\"\nimport { useLifelineHoverImage } from \"./lifeline-hover-image\"\nimport { aggregateLifelinePeople, LifelinePeople } from \"./lifeline-people\"\nimport type { LifelineMarker } from \"./types\"\n\ninterface LifelineMarkerColumnProps {\n  marker: LifelineMarker\n  birthYear: number\n  minWidth: number\n  animateIntro?: boolean\n  introDelay?: number\n  introDuration?: number\n}\n\nexport const LifelineMarkerColumn = forwardRef<\n  HTMLDivElement,\n  LifelineMarkerColumnProps\n>(function LifelineMarkerColumn(\n  {\n    marker,\n    birthYear,\n    minWidth,\n    animateIntro = false,\n    introDelay = 0,\n    introDuration = 420,\n  },\n  ref,\n) {\n  const age = marker.age ?? marker.year - birthYear\n  const people = aggregateLifelinePeople(marker)\n  const hoverImage = useLifelineHoverImage()\n  const fireworks = useLifelineFireworks()\n\n  return (\n    <div\n      ref={ref}\n      className=\"group relative shrink-0 pr-8 transition-opacity duration-300 ease-out will-change-opacity\"\n      style={{ width: minWidth }}\n      aria-label={marker.label ?? `${marker.year}`}\n    >\n      <div\n        className={cn(\"relative\", animateIntro && \"lifeline-marker-intro\")}\n        style={{\n          animationDelay: animateIntro ? `${introDelay}ms` : undefined,\n          ...(animateIntro\n            ? ({\n                \"--lifeline-marker-fade-ms\": `${introDuration}ms`,\n              } as CSSProperties)\n            : {}),\n        }}\n      >\n        <span\n          className=\"absolute left-0 top-[var(--lifeline-rail)] z-10 h-[10px] w-px -translate-y-1/2 bg-zinc-400 transition-colors duration-300 group-hover:bg-zinc-600 dark:bg-zinc-700 dark:group-hover:bg-zinc-400\"\n          aria-hidden=\"true\"\n        />\n\n        <div className=\"flex w-full flex-col items-start text-left\">\n          <p className=\"mb-5 h-4 text-[11px] font-medium leading-4 tabular-nums text-zinc-500 transition-colors duration-300 group-hover:text-black dark:text-zinc-600 dark:group-hover:text-zinc-400\">\n            {age}\n          </p>\n\n          <p className=\"mb-6 h-5 whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-zinc-500 transition-colors duration-300 group-hover:text-black dark:group-hover:text-white\">\n            {marker.label ?? marker.year}\n          </p>\n\n          <div className=\"relative w-full pb-10 text-zinc-500 transition-colors duration-300 group-hover:text-black dark:group-hover:text-zinc-300\">\n            {/* When this column carries people, the content block reserves\n                the band's height as a floor: short and average columns put\n                their portraits on the same line as every other column, and\n                a column whose events run past the floor pushes its own\n                portraits below them instead of under them. pb-6 is the gap\n                in the overflow case — absorbed by the floor otherwise. */}\n            <div\n              className={cn(\n                \"flex w-full flex-col items-start pt-6\",\n                people.length > 0 &&\n                  \"min-h-[var(--lifeline-people-top)] pb-6\",\n              )}\n            >\n              {marker.badges && marker.badges.length > 0 && (\n                <div className=\"mb-3 flex items-center justify-start gap-2\">\n                  {marker.badges.map((badge) => (\n                    // eslint-disable-next-line @next/next/no-img-element\n                    <img\n                      key={badge.src}\n                      src={badge.src}\n                      alt={badge.alt}\n                      className=\"h-6 w-6 object-contain opacity-80 transition-opacity duration-300 group-hover:opacity-100\"\n                    />\n                  ))}\n                </div>\n              )}\n\n              {marker.companies && marker.companies.length > 0 && (\n                <div className=\"mb-2 flex items-center justify-start gap-1.5\">\n                  {marker.companies.map((company) => (\n                    <CompanyIcon\n                      key={company.id}\n                      id={company.id}\n                      label={company.name}\n                      className=\"opacity-70 transition-opacity duration-300 group-hover:opacity-100\"\n                    />\n                  ))}\n                </div>\n              )}\n\n              <div className=\"min-h-[3.25rem] space-y-4\">\n                {marker.events.map((event, index) => {\n                  const image = getLifelineEventImage(event)\n                  const effect = getLifelineEventEffect(event)\n\n                  return (\n                    <p\n                      key={getLifelineEventKey(event, index)}\n                      className={cn(\n                        \"max-w-[18rem] text-left text-[14px] leading-[1.55] tracking-[-0.01em]\",\n                        effect && \"cursor-pointer\",\n                      )}\n                      data-lifeline-interactive={effect ? \"\" : undefined}\n                      onMouseEnter={\n                        image && hoverImage\n                          ? () => hoverImage.show(image)\n                          : undefined\n                      }\n                      onMouseLeave={\n                        image && hoverImage ? hoverImage.hide : undefined\n                      }\n                      onClick={\n                        effect && fireworks\n                          ? () => fireworks.launch(effect)\n                          : undefined\n                      }\n                    >\n                      <LifelineEventText event={event} />\n                      {image && (\n                        // Glued to the last word with a no-break space so\n                        // the icon can never wrap onto a line of its own.\n                        <span className=\"whitespace-nowrap\">\n                          {\" \"}\n                          {image.video ? (\n                            <Film\n                              className=\"ml-0.5 inline-block h-3 w-3 -translate-y-px text-zinc-400 transition-colors duration-300 dark:text-zinc-600\"\n                              strokeWidth={1.75}\n                              aria-hidden=\"true\"\n                            />\n                          ) : (\n                            <ImageIcon\n                              className=\"ml-0.5 inline-block h-3 w-3 -translate-y-px text-zinc-400 transition-colors duration-300 dark:text-zinc-600\"\n                              strokeWidth={1.75}\n                              aria-hidden=\"true\"\n                            />\n                          )}\n                        </span>\n                      )}\n                    </p>\n                  )\n                })}\n              </div>\n            </div>\n\n            {people.length > 0 && (\n              <div className=\"w-full\">\n                <LifelinePeople people={people} />\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n})",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-marker.tsx"
    },
    {
      "path": "components/lifeline/lifeline-people.tsx",
      "content": "import Image from \"next/image\"\nimport type { LifelineMarker } from \"./types\"\n\nexport interface AggregatedLifelinePerson {\n  name: string\n  mentor: boolean\n  met: boolean\n  photo?: string\n}\n\nexport function aggregateLifelinePeople(\n  marker: LifelineMarker,\n): AggregatedLifelinePerson[] {\n  const map = new Map<string, AggregatedLifelinePerson>()\n\n  const add = (\n    name: string,\n    type: \"mentor\" | \"met\",\n    photo?: string,\n  ) => {\n    const person = map.get(name) ?? { name, mentor: false, met: false }\n    person[type] = true\n    person.photo = person.photo ?? photo\n    map.set(name, person)\n  }\n\n  marker.mentors?.forEach((person) =>\n    add(person.name, \"mentor\", person.photo),\n  )\n  marker.met?.forEach((person) => add(person.name, \"met\", person.photo))\n\n  return [...map.values()]\n}\n\nfunction getInitials(name: string) {\n  return name\n    .split(\" \")\n    .filter(Boolean)\n    .map((part) => part[0])\n    .join(\"\")\n    .slice(0, 2)\n}\n\ninterface LifelinePeopleProps {\n  people: AggregatedLifelinePerson[]\n  allowWrap?: boolean\n}\n\nexport function LifelinePeople({\n  people,\n  allowWrap = false,\n}: LifelinePeopleProps) {\n  if (people.length === 0) return null\n\n  return (\n    <div className=\"w-full space-y-3\">\n      {people.map((person) => (\n        <div key={person.name} className=\"flex w-full items-center gap-2.5\">\n          <div className=\"flex w-3 shrink-0 items-center justify-center gap-0.5\">\n            {person.mentor && (\n              <span\n                className=\"h-1.5 w-1.5 rounded-full bg-blue-500\"\n                aria-hidden=\"true\"\n              />\n            )}\n            {person.met && (\n              <span\n                className=\"h-1.5 w-1.5 rounded-full bg-pink-500\"\n                aria-hidden=\"true\"\n              />\n            )}\n          </div>\n          {person.photo ? (\n            <Image\n              src={person.photo}\n              alt={person.name}\n              width={28}\n              height={28}\n              className=\"h-7 w-7 shrink-0 rounded-full object-cover\"\n            />\n          ) : (\n            <span className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-black text-[10px] font-medium text-white transition-colors duration-300 dark:bg-white dark:text-black\">\n              {getInitials(person.name)}\n            </span>\n          )}\n          <p\n            className={\n              allowWrap\n                ? \"text-left text-[13px] leading-snug text-zinc-500 transition-colors duration-300\"\n                : \"whitespace-nowrap text-left text-[13px] text-zinc-500 transition-colors duration-300 group-hover:text-zinc-700 dark:group-hover:text-zinc-400\"\n            }\n          >\n            {person.name}\n          </p>\n        </div>\n      ))}\n    </div>\n  )\n}",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-people.tsx"
    },
    {
      "path": "components/lifeline/lifeline-photos.tsx",
      "content": "\"use client\"\n\nimport { useRef, useState, type CSSProperties, type PointerEvent } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { LifelineEventMedia } from \"./lifeline-event\"\nimport {\n  LifelineLightbox,\n  type LifelineLightboxStart,\n} from \"./lifeline-lightbox\"\nimport type { LifelineMarker, LifelinePhoto } from \"./types\"\n\n/** Tweak these */\nconst CARD_WIDTH = 180\n/** Matches the events column's pt-6 — cards align with the first event's text. */\nconst EVENT_TOP = 24\nconst MAX_TILT_DEG = 6\n/**\n * How far along a card the next one in the stack starts — 0.6 leaves\n * a solid margin of every card visible under its neighbor.\n */\nconst STACK_OVERLAP = 0.6\n/**\n * Stacks cascade diagonally: the last card sits level with the event\n * text and each one before it hangs this much lower, so neighbors\n * overlap corner-to-corner instead of side-by-side.\n */\nconst CASCADE_Y = 170\n/** Event text column: max-w-[18rem] plus breathing room. */\nconst TEXT_ZONE = 288 + 24\n/** Pointer travel below this is a click (opens the lightbox), not a drag. */\nconst CLICK_SLOP = 4\n/** Fingers wobble more than mice — touch presses get extra tap room. */\nconst TOUCH_CLICK_SLOP = 10\n\n/** A fresh tilt on every visit — rolled once per card mount. */\nfunction randomTilt() {\n  return 2 + Math.random() * (MAX_TILT_DEG - 2)\n}\n\n/**\n * The interactive photo card, positioning-agnostic: drag moves it for\n * the session, a press without travel expands it into the lightbox.\n * Desktop floats it over the track (absolute + left/top); the vertical\n * layout drops it into normal flow.\n */\nexport function LifelinePhotoCard({\n  photo,\n  rotate,\n  width,\n  className,\n  style,\n  animateIntro = false,\n  introDelay = 0,\n  introDuration = 420,\n}: {\n  photo: LifelinePhoto\n  /** Resolved resting tilt, degrees. */\n  rotate: number\n  width: number\n  /** Positioning context from the caller (e.g. \"absolute\"). */\n  className?: string\n  style?: CSSProperties\n  animateIntro?: boolean\n  introDelay?: number\n  introDuration?: number\n}) {\n  const [offset, setOffset] = useState({ x: 0, y: 0 })\n  const [active, setActive] = useState(false)\n  const [lightboxStart, setLightboxStart] =\n    useState<LifelineLightboxStart | null>(null)\n  const cardRef = useRef<HTMLDivElement>(null)\n  const drag = useRef({\n    startX: 0,\n    startY: 0,\n    baseX: 0,\n    baseY: 0,\n    moved: false,\n    slop: CLICK_SLOP,\n  })\n\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    // The desktop track scrubs on drag — a card drag must not reach it.\n    event.stopPropagation()\n    event.preventDefault()\n    event.currentTarget.setPointerCapture(event.pointerId)\n    drag.current = {\n      startX: event.clientX,\n      startY: event.clientY,\n      baseX: offset.x,\n      baseY: offset.y,\n      moved: false,\n      slop: event.pointerType === \"touch\" ? TOUCH_CLICK_SLOP : CLICK_SLOP,\n    }\n    setActive(true)\n  }\n\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (!active) return\n    const dx = event.clientX - drag.current.startX\n    const dy = event.clientY - drag.current.startY\n    if (Math.hypot(dx, dy) > drag.current.slop) drag.current.moved = true\n    setOffset({ x: drag.current.baseX + dx, y: drag.current.baseY + dy })\n  }\n\n  // The card's real geometry: bounding-box center (rotation preserves\n  // it) plus untransformed layout size — never the rotated hull, which\n  // is what made the lightbox clone jump on open.\n  const measureCard = (): LifelineLightboxStart | null => {\n    const el = cardRef.current\n    if (!el) return null\n    const rect = el.getBoundingClientRect()\n    // Recover the rendered scale (hover grows the card 3%) from the\n    // rotated hull: for tilt θ, hullWidth = (w·cosθ + h·sinθ)·scale.\n    const w0 = el.offsetWidth\n    const h0 = el.offsetHeight\n    const rad = Math.abs((rotate * Math.PI) / 180)\n    const hull = w0 * Math.cos(rad) + h0 * Math.sin(rad)\n    const scale = hull > 0 ? rect.width / hull : 1\n    return {\n      cx: rect.left + rect.width / 2,\n      cy: rect.top + rect.height / 2,\n      w: w0 * scale,\n      h: h0 * scale,\n      mediaTime: el.querySelector(\"video\")?.currentTime,\n    }\n  }\n\n  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.releasePointerCapture(event.pointerId)\n    setActive(false)\n    // A press that never travelled is a click — expand to the lightbox.\n    if (!drag.current.moved && !lightboxStart) {\n      setLightboxStart(measureCard())\n    }\n  }\n\n  // The browser claiming the gesture (a vertical pan-y scroll on\n  // touch) is not a click — reset without opening.\n  const onPointerCancel = (event: PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.releasePointerCapture(event.pointerId)\n    setActive(false)\n    setOffset({ x: drag.current.baseX, y: drag.current.baseY })\n  }\n\n  return (\n    <>\n      <div\n        ref={cardRef}\n        data-lifeline-interactive=\"\"\n        className={cn(\n          // pan-y keeps page scrolling alive on touch: a vertical swipe\n          // starting on a card scrolls the timeline (the browser claims\n          // the gesture and fires pointercancel); horizontal drags move\n          // the card.\n          \"group/photo pointer-events-auto cursor-grab touch-pan-y\",\n          active ? \"z-50 cursor-grabbing\" : \"z-20 hover:z-40\",\n          lightboxStart && \"invisible\",\n          className,\n        )}\n        style={\n          {\n            ...style,\n            width,\n            transform: `translate(${offset.x}px, ${offset.y}px) rotate(${rotate}deg)`,\n          } as CSSProperties\n        }\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerCancel}\n      >\n        <div\n          className={cn(\n            \"overflow-hidden rounded-xl shadow-xl ring-1 ring-black/10 transition-[transform,box-shadow] duration-200 ease-out dark:ring-white/15\",\n            animateIntro && \"lifeline-marker-intro\",\n            active\n              ? \"scale-[1.05] shadow-2xl\"\n              : \"group-hover/photo:scale-[1.03] group-hover/photo:shadow-2xl\",\n          )}\n          style={\n            animateIntro\n              ? ({\n                  animationDelay: `${introDelay}ms`,\n                  \"--lifeline-marker-fade-ms\": `${introDuration}ms`,\n                } as CSSProperties)\n              : undefined\n          }\n        >\n          <LifelineEventMedia\n            media={photo}\n            className=\"pointer-events-none block w-full\"\n          />\n        </div>\n      </div>\n      {lightboxStart && (\n        <LifelineLightbox\n          photo={photo}\n          rotate={rotate}\n          start={lightboxStart}\n          getHome={measureCard}\n          onClosed={() => setLightboxStart(null)}\n        />\n      )}\n    </>\n  )\n}\n\nfunction FloatingCard({\n  photo,\n  stackIndex,\n  stackCount,\n  x,\n  defaultY,\n  width,\n  animateIntro,\n  introDelay,\n  introDuration,\n}: {\n  photo: LifelinePhoto\n  stackIndex: number\n  stackCount: number\n  /** Resolved left position within the track. */\n  x: number\n  /** Cascade position when the photo has no explicit y. */\n  defaultY: number\n  width: number\n  animateIntro: boolean\n  introDelay: number\n  introDuration: number\n}) {\n  const y = photo.y ?? defaultY\n  // Rolled once per mount: solo cards flip a coin for direction;\n  // neighbors in a stack lean away from each other so the pile reads\n  // as scattered.\n  const [mountTilt] = useState(() => {\n    const sign =\n      stackCount > 1\n        ? stackIndex % 2 === 0\n          ? -1\n          : 1\n        : Math.random() > 0.5\n          ? 1\n          : -1\n    return sign * randomTilt()\n  })\n\n  return (\n    <LifelinePhotoCard\n      photo={photo}\n      rotate={photo.rotate ?? mountTilt}\n      width={width}\n      className=\"absolute\"\n      style={{ left: x, top: `calc(var(--lifeline-rail) + ${y}px)` }}\n      animateIntro={animateIntro}\n      introDelay={introDelay}\n      introDuration={introDuration}\n    />\n  )\n}\n\n/**\n * Always-visible media scattered over the timeline — anchored to their\n * marker's slot, tilted and overlapping like photos in a notebook.\n * Rendered inside the transformed track, so they ride the scroll;\n * dragging repositions a card for the session.\n */\nexport function LifelineFloatingPhotos({\n  markers,\n  offsets,\n  widths,\n  animateIntro = false,\n  getIntroDelay,\n  getIntroDuration,\n}: {\n  markers: LifelineMarker[]\n  offsets: number[]\n  widths: number[]\n  animateIntro?: boolean\n  getIntroDelay?: (markerIndex: number) => number\n  getIntroDuration?: (markerIndex: number) => number\n}) {\n  const trackEnd =\n    offsets.length > 0\n      ? offsets[offsets.length - 1] + widths[widths.length - 1]\n      : 0\n\n  // Intro sync: a card fades in when the rail tip reaches it, i.e. on\n  // the schedule of the marker whose slot its center sits over — which\n  // is usually days past its anchor.\n  const markerIndexAt = (x: number) => {\n    for (let index = offsets.length - 1; index >= 0; index--) {\n      if (offsets[index] <= x) return index\n    }\n    return 0\n  }\n\n  return (\n    <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n      {markers.map((marker, index) => {\n        if (!marker.photos?.length) return null\n\n        // The card's free run: after this day's own text column, up to\n        // the start of the next day that has text of its own.\n        const zoneStart =\n          offsets[index] + (marker.events.length > 0 ? TEXT_ZONE : 0)\n        const nextTextIndex = markers.findIndex(\n          (candidate, candidateIndex) =>\n            candidateIndex > index && candidate.events.length > 0,\n        )\n        const zoneEnd =\n          nextTextIndex === -1 ? trackEnd : offsets[nextTextIndex]\n\n        // Stacks center as a group so a lone card sits in the middle\n        // of the gap and a pile spreads evenly around it. Each card\n        // starts STACK_OVERLAP of the way along the one beneath it.\n        const steps: number[] = []\n        let fan = 0\n        for (const stacked of marker.photos) {\n          steps.push(fan)\n          fan += (stacked.width ?? CARD_WIDTH) * STACK_OVERLAP\n        }\n        const photoCount = marker.photos.length\n        const lastPhoto = marker.photos[photoCount - 1]\n        const groupWidth =\n          steps[steps.length - 1] + (lastPhoto.width ?? CARD_WIDTH)\n\n        return marker.photos.map((photo, photoIndex) => {\n          const width = photo.width ?? CARD_WIDTH\n          // Default home: centered in the text-free run between this\n          // day's events and the next day that has text — comfortably\n          // away from both columns.\n          const x =\n            photo.x !== undefined\n              ? offsets[index] + photo.x * widths[index]\n              : Math.max(\n                  zoneStart,\n                  zoneStart +\n                    (zoneEnd - zoneStart - groupWidth) / 2 +\n                    steps[photoIndex],\n                )\n          const introIndex = markerIndexAt(x + width / 2)\n          // The last card of a stack sits level with the event text;\n          // earlier cards hang progressively lower — the diagonal.\n          const defaultY =\n            EVENT_TOP + (photoCount - 1 - photoIndex) * CASCADE_Y\n\n          return (\n            <FloatingCard\n              key={`${marker.id}-${photoIndex}`}\n              photo={photo}\n              stackIndex={photoIndex}\n              stackCount={photoCount}\n              x={x}\n              defaultY={defaultY}\n              width={width}\n              animateIntro={animateIntro}\n              introDelay={getIntroDelay?.(introIndex) ?? 0}\n              introDuration={getIntroDuration?.(introIndex) ?? 420}\n            />\n          )\n        })\n      })}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-photos.tsx"
    },
    {
      "path": "components/lifeline/lifeline-utils.ts",
      "content": "import type { LifelineMarker } from \"./types\"\n\nexport function clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value))\n}\n\nexport function hasMarkerContent(marker: LifelineMarker) {\n  return (\n    marker.events.length > 0 ||\n    (marker.companies?.length ?? 0) > 0 ||\n    (marker.mentors?.length ?? 0) > 0 ||\n    (marker.met?.length ?? 0) > 0\n  )\n}\n\nexport function hasMarkerPeople(marker: LifelineMarker) {\n  return (marker.mentors?.length ?? 0) > 0 || (marker.met?.length ?? 0) > 0\n}\n\nexport function getMarkerHeight(marker: LifelineMarker, nextYear?: number) {\n  const hasContent = hasMarkerContent(marker)\n  const hasPeople = hasMarkerPeople(marker)\n\n  if (!hasContent) return 48\n\n  const peopleOnly =\n    hasPeople &&\n    marker.events.length === 0 &&\n    (marker.companies?.length ?? 0) === 0\n\n  let height = 96\n\n  if (marker.companies?.length) height += 28\n  height += marker.events.length * 44\n\n  if (peopleOnly) height += 88\n  else if (hasPeople) height += 108\n\n  if (!nextYear) return Math.min(520, Math.max(peopleOnly ? 148 : 188, height))\n\n  const gap = Math.max(1, nextYear - marker.year)\n  height += Math.min(32, gap * 3)\n\n  return Math.min(520, Math.max(peopleOnly ? 148 : 188, height))\n}\n\nexport function getMarkerWidth(marker: LifelineMarker, nextYear?: number) {\n  const hasContent = hasMarkerContent(marker)\n  const hasPeople = hasMarkerPeople(marker)\n\n  if (!nextYear) return hasContent ? 360 : 80\n  if (!hasContent) return 80\n\n  const peopleOnly =\n    hasPeople &&\n    marker.events.length === 0 &&\n    (marker.companies?.length ?? 0) === 0\n\n  if (peopleOnly) return 220\n\n  const gap = Math.max(1, nextYear - marker.year)\n  return Math.min(420, Math.max(290, gap * 36))\n}",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-utils.ts"
    },
    {
      "path": "components/lifeline/lifeline-vertical.tsx",
      "content": "\"use client\"\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\"\nimport { Film, Image as ImageIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { CompanyIcon } from \"./company-icon\"\nimport {\n  getLifelineEventEffect,\n  getLifelineEventImage,\n  getLifelineEventKey,\n  LifelineEventText,\n} from \"./lifeline-event\"\nimport { useLifelineFireworks } from \"./lifeline-fireworks\"\nimport {\n  LifelineLightbox,\n  type LifelineLightboxStart,\n} from \"./lifeline-lightbox\"\nimport { aggregateLifelinePeople, LifelinePeople } from \"./lifeline-people\"\nimport { LifelinePhotoCard } from \"./lifeline-photos\"\nimport type { LifelineEvent, LifelineMarker, LifelineProps } from \"./types\"\nimport { getMarkerHeight, hasMarkerContent } from \"./lifeline-utils\"\nimport { useLifelineIntro } from \"./use-lifeline-intro\"\nimport { useLifelineVerticalScroll } from \"./use-lifeline-vertical-scroll\"\n\nconst GRID_CLASS = \"grid grid-cols-[2.5rem_1rem_1fr] gap-x-3\"\nconst RAIL_LEFT = \"calc(2.5rem + 0.75rem + 0.5rem)\"\n\n/**\n * Above this many entries the delay-armed intro fades would promote\n * every entry to a compositor layer at once and crash mobile Safari's\n * compositor. Long timelines fade entries in as they enter the\n * viewport during the auto-scroll instead — same look, but only a\n * handful of live animations at any moment.\n */\nconst MAX_ARMED_ENTRIES = 80\n\nfunction RailTick() {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"block h-px w-[10px] bg-zinc-400 transition-colors duration-300 dark:bg-zinc-700\"\n    />\n  )\n}\n\n/**\n * One event line. Touch layouts have no hover reveal, so an event with\n * attached media becomes tappable: the media expands into the lightbox\n * from the event's text, framed by the poster image's real aspect.\n */\nfunction LifelineVerticalEvent({ event }: { event: LifelineEvent }) {\n  const fireworks = useLifelineFireworks()\n  const image = getLifelineEventImage(event)\n  const effect = getLifelineEventEffect(event)\n  const textRef = useRef<HTMLParagraphElement>(null)\n  const aspectRef = useRef(3 / 4)\n  const [lightboxStart, setLightboxStart] =\n    useState<LifelineLightboxStart | null>(null)\n\n  // The event text has no card geometry — synthesize a small seed\n  // centered on the text, carrying the media's aspect so the lightbox\n  // expands into the right frame.\n  const measureText = useCallback((): LifelineLightboxStart | null => {\n    const el = textRef.current\n    if (!el) return null\n    const rect = el.getBoundingClientRect()\n    const w = 96\n    return {\n      cx: rect.left + rect.width / 2,\n      cy: rect.top + rect.height / 2,\n      w,\n      h: w * aspectRef.current,\n    }\n  }, [])\n\n  const openMedia = () => {\n    if (!image || lightboxStart) return\n    // The poster sets the frame; for videos it shares the clip's aspect.\n    const probe = new window.Image()\n    probe.src = image.src\n    const open = () => {\n      if (probe.naturalWidth > 0) {\n        aspectRef.current = probe.naturalHeight / probe.naturalWidth\n      }\n      setLightboxStart(measureText())\n    }\n    if (probe.complete) {\n      open()\n    } else {\n      probe.onload = open\n      probe.onerror = open\n    }\n  }\n\n  return (\n    <>\n      <p\n        ref={textRef}\n        className={cn(\n          \"max-w-[18rem] text-left text-[14px] leading-[1.55] tracking-[-0.01em]\",\n          (image || effect) && \"cursor-pointer\",\n        )}\n        onClick={\n          image\n            ? openMedia\n            : effect && fireworks\n              ? () => fireworks.launch(effect)\n              : undefined\n        }\n      >\n        <LifelineEventText event={event} />\n        {image && (\n          // Glued to the last word with a no-break space so the icon\n          // can never wrap onto a line of its own.\n          <span className=\"whitespace-nowrap\">\n            {\" \"}\n            {image.video ? (\n              <Film\n                className=\"ml-0.5 inline-block h-3 w-3 -translate-y-px text-zinc-400 transition-colors duration-300 dark:text-zinc-600\"\n                strokeWidth={1.75}\n                aria-hidden=\"true\"\n              />\n            ) : (\n              <ImageIcon\n                className=\"ml-0.5 inline-block h-3 w-3 -translate-y-px text-zinc-400 transition-colors duration-300 dark:text-zinc-600\"\n                strokeWidth={1.75}\n                aria-hidden=\"true\"\n              />\n            )}\n          </span>\n        )}\n      </p>\n      {lightboxStart && image && (\n        <LifelineLightbox\n          photo={image}\n          rotate={0}\n          start={lightboxStart}\n          getHome={measureText}\n          onClosed={() => setLightboxStart(null)}\n        />\n      )}\n    </>\n  )\n}\n\nconst LifelineVerticalEntry = forwardRef<\n  HTMLLIElement,\n  {\n    marker: LifelineMarker\n    birthYear: number\n    animateIntro?: boolean\n    introDelay?: number\n    introDuration?: number\n    revealPending?: boolean\n  }\n>(function LifelineVerticalEntry(\n  {\n    marker,\n    birthYear,\n    animateIntro = false,\n    introDelay = 0,\n    introDuration = 420,\n    revealPending = false,\n  },\n  ref,\n) {\n  const age = marker.age ?? marker.year - birthYear\n  const people = aggregateLifelinePeople(marker)\n  const photos = marker.photos ?? []\n  const hasContent = hasMarkerContent(marker) || photos.length > 0\n  // Fresh tilts per visit; stacked neighbors lean apart.\n  const [photoTilts] = useState(() =>\n    (marker.photos ?? []).map((_, index) => {\n      const sign =\n        (marker.photos?.length ?? 0) > 1\n          ? index % 2 === 0\n            ? -1\n            : 1\n          : Math.random() > 0.5\n            ? 1\n            : -1\n      return sign * (2 + Math.random() * 4)\n    }),\n  )\n\n  return (\n    <li\n      ref={ref}\n      className={hasContent ? \"pb-10\" : \"pb-3\"}\n      aria-label={marker.label ?? `${marker.year}`}\n    >\n      <div\n        className={cn(\n          animateIntro && \"lifeline-marker-intro\",\n          revealPending && \"opacity-0\",\n        )}\n        style={{\n          animationDelay: animateIntro ? `${introDelay}ms` : undefined,\n          ...(animateIntro\n            ? ({\n                \"--lifeline-marker-fade-ms\": `${introDuration}ms`,\n              } as CSSProperties)\n            : {}),\n        }}\n      >\n        <div className={`${GRID_CLASS} items-center`}>\n          <p className=\"text-right text-[11px] font-medium leading-4 tabular-nums text-zinc-500 transition-colors duration-300 dark:text-zinc-600\">\n            {age}\n          </p>\n\n          <div className=\"flex items-center justify-center\">\n            <RailTick />\n          </div>\n\n          <p className=\"whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-zinc-500 transition-colors duration-300 dark:text-zinc-400\">\n            {marker.label ?? marker.year}\n          </p>\n        </div>\n\n        {hasContent && (\n          <div className={`${GRID_CLASS} mt-6`}>\n            <div aria-hidden=\"true\" />\n            <div aria-hidden=\"true\" />\n            <div className=\"min-w-0 text-zinc-500 transition-colors duration-300 dark:text-zinc-400\">\n              {marker.badges && marker.badges.length > 0 && (\n                <div className=\"mb-3 flex items-center justify-start gap-2\">\n                  {marker.badges.map((badge) => (\n                    // eslint-disable-next-line @next/next/no-img-element\n                    <img\n                      key={badge.src}\n                      src={badge.src}\n                      alt={badge.alt}\n                      className=\"h-6 w-6 object-contain opacity-80\"\n                    />\n                  ))}\n                </div>\n              )}\n\n              {marker.companies && marker.companies.length > 0 && (\n                <div className=\"mb-2 flex items-center justify-start gap-1.5\">\n                  {marker.companies.map((company) => (\n                    <CompanyIcon\n                      key={company.id}\n                      id={company.id}\n                      label={company.name}\n                      className=\"opacity-70\"\n                    />\n                  ))}\n                </div>\n              )}\n\n              {marker.events.length > 0 && (\n                <div className=\"space-y-4\">\n                  {marker.events.map((event, index) => (\n                    <LifelineVerticalEvent\n                      key={getLifelineEventKey(event, index)}\n                      event={event}\n                    />\n                  ))}\n                </div>\n              )}\n\n              {photos.length > 0 && (\n                <div className=\"mt-6 flex flex-wrap items-start\">\n                  {photos.map((photo, index) => (\n                    <LifelinePhotoCard\n                      key={`${photo.src}-${index}`}\n                      photo={photo}\n                      rotate={photo.rotate ?? photoTilts[index] ?? 0}\n                      width={160}\n                      className={cn(\n                        \"relative\",\n                        index > 0 && \"-ml-8 mt-6\",\n                      )}\n                    />\n                  ))}\n                </div>\n              )}\n\n              {people.length > 0 && (\n                <div className=\"mt-6 border-t border-zinc-200/70 pt-5 transition-colors duration-300 dark:border-zinc-800/70\">\n                  <LifelinePeople people={people} allowWrap />\n                </div>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    </li>\n  )\n})\n\nexport function LifelineVertical({\n  markers,\n  birthYear,\n  title = \"Lifeline\",\n  mode = \"auto\",\n}: LifelineProps) {\n  // Only an explicit `mode` embeds the vertical layout. `\"auto\"` measures\n  // scrollability on desktop, but the mobile layout *is* a vertical\n  // scroller inside a scrolling stage, so that test would read every\n  // full-page timeline as embedded and drop its intro.\n  const isEmbed = mode === \"embed\"\n  const heights = useMemo(\n    () =>\n      markers.map((marker, index) =>\n        getMarkerHeight(marker, markers[index + 1]?.year),\n      ),\n    [markers],\n  )\n\n  const intro = useLifelineIntro(heights)\n  const isIntroAnimating = intro.shouldPlay && intro.isPlaying\n\n  // Warm the event media posters during idle — the tap-to-open\n  // lightbox measures its frame from these, and a cold fetch at tap\n  // time reads as lag.\n  useEffect(() => {\n    const sources: string[] = []\n    for (const marker of markers) {\n      for (const event of marker.events) {\n        const image = getLifelineEventImage(event)\n        if (image) sources.push(image.src)\n      }\n    }\n    if (sources.length === 0) return\n\n    const warm = () => {\n      sources.forEach((src) => {\n        const image = new window.Image()\n        image.src = src\n      })\n    }\n\n    if (typeof window.requestIdleCallback === \"function\") {\n      const handle = window.requestIdleCallback(warm)\n      return () => window.cancelIdleCallback(handle)\n    }\n    const timeout = window.setTimeout(warm, 2000)\n    return () => window.clearTimeout(timeout)\n  }, [markers])\n\n  const { sectionRef, setEntryRef, isLayoutReady } = useLifelineVerticalScroll(\n    markers.length,\n    {\n      isEmbed,\n      introLocked: isIntroAnimating,\n      introAnimating: isIntroAnimating,\n      // Embedded, the sweep would play out unseen below the fold — and\n      // lock the module's own scroller while doing it.\n      introSkipped: !intro.shouldPlay || isEmbed,\n      introRailMs: intro.railDuration,\n      introGetTrackProgress: intro.getTrackProgressAtTime,\n      onIntroScrollStart: intro.startIntroTimer,\n      onIntroSettleComplete: intro.completeIntro,\n    },\n  )\n\n  const showIntro = isIntroAnimating && isLayoutReady && !isEmbed\n  const revealOnScroll = markers.length > MAX_ARMED_ENTRIES\n  const animateEntries = showIntro && !revealOnScroll\n\n  // Rail-synced fades for long timelines: entries render hidden and\n  // each one fades in the moment the rail tip (--lifeline-intro-progress,\n  // written every frame by the intro scroll) crosses its position —\n  // desktop's choreography, but each entry drops its animation (and\n  // compositor layer) as soon as its fade finishes.\n  useEffect(() => {\n    if (!showIntro || !revealOnScroll) return\n    const section = sectionRef.current\n    const ol = section?.querySelector(\"ol\")\n    if (!section || !ol) return\n\n    const entries = Array.from(ol.children) as HTMLElement[]\n    const targets = entries.map(\n      (li) => li.firstElementChild as HTMLElement | null,\n    )\n\n    const onAnimationEnd = (event: AnimationEvent) => {\n      if (event.animationName !== \"lifeline-marker-in\") return\n      ;(event.target as HTMLElement).classList.remove(\"lifeline-marker-intro\")\n    }\n    section.addEventListener(\"animationend\", onAnimationEnd)\n\n    let next = 0\n    let frame = 0\n    const tick = () => {\n      const progress = parseFloat(\n        section.style.getPropertyValue(\"--lifeline-intro-progress\") || \"0\",\n      )\n      const tip = progress * ol.offsetHeight\n\n      while (next < entries.length && entries[next].offsetTop <= tip) {\n        const el = targets[next]\n        if (el) {\n          el.classList.remove(\"opacity-0\")\n          el.classList.add(\"lifeline-marker-intro\")\n        }\n        next++\n      }\n\n      if (next < entries.length) {\n        frame = requestAnimationFrame(tick)\n      }\n    }\n    frame = requestAnimationFrame(tick)\n\n    return () => {\n      cancelAnimationFrame(frame)\n      section.removeEventListener(\"animationend\", onAnimationEnd)\n      targets.forEach((el) => {\n        el?.classList.remove(\"opacity-0\", \"lifeline-marker-intro\")\n      })\n    }\n  }, [showIntro, revealOnScroll, sectionRef])\n\n  const introStyle = {\n    \"--lifeline-labels-ms\": `${intro.labelsDuration}ms`,\n    \"--lifeline-rail-ms\": `${intro.railDuration}ms`,\n  } as CSSProperties\n\n  return (\n    <article\n      ref={sectionRef}\n      aria-label={title}\n      className={cn(\n        \"relative select-none px-6 pb-10 pt-4 [&_a]:cursor-pointer\",\n        !isLayoutReady && \"invisible\",\n      )}\n      style={showIntro ? introStyle : undefined}\n    >\n      <div className={cn(`${GRID_CLASS} mb-6 items-end`, showIntro && \"lifeline-labels-intro\")}>\n        <p className=\"text-right text-[11px] font-medium uppercase leading-4 tracking-[0.08em] text-zinc-500 transition-colors duration-300 dark:text-zinc-600\">\n          Age\n        </p>\n        <div aria-hidden=\"true\" />\n        <p className=\"text-[11px] font-medium uppercase leading-5 tracking-[0.08em] text-zinc-500 transition-colors duration-300 dark:text-zinc-600\">\n          Years\n        </p>\n      </div>\n\n      <div className=\"relative\">\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-0 top-0 overflow-hidden -translate-x-1/2\"\n          style={{ left: RAIL_LEFT, width: 1 }}\n        >\n          <div\n            className={cn(\n              \"h-full w-px border-l border-dashed border-zinc-300 transition-colors duration-300 dark:border-zinc-800\",\n              showIntro && \"lifeline-rail-intro-vertical\",\n            )}\n          />\n        </div>\n\n        <ol className=\"relative\">\n          {markers.map((marker, index) => (\n            <LifelineVerticalEntry\n              key={marker.id}\n              ref={(node) => setEntryRef(index, node)}\n              marker={marker}\n              birthYear={birthYear}\n              animateIntro={animateEntries}\n              revealPending={showIntro && revealOnScroll}\n              introDelay={intro.getMarkerDelay(index)}\n              introDuration={intro.getMarkerFadeDuration(index)}\n            />\n          ))}\n        </ol>\n      </div>\n    </article>\n  )\n}",
      "type": "registry:component",
      "target": "components/lifeline/lifeline-vertical.tsx"
    },
    {
      "path": "components/lifeline/lifeline.tsx",
      "content": "\"use client\"\n\nimport { useLayoutEffect, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { LifelineDesktop } from \"./lifeline-desktop\"\nimport { LifelineFireworksProvider } from \"./lifeline-fireworks\"\nimport { LifelineVertical } from \"./lifeline-vertical\"\nimport { LIFELINE_MOBILE_BREAKPOINT } from \"./lifeline-layout\"\nimport type { LifelineProps } from \"./types\"\n\n/**\n * `lifeline-typeset` carries the timeline's own font stack (Geist, falling\n * back to the system sans) rather than inheriting the host's `font-sans`.\n * A shadcn init writes a self-referential `--font-sans` into the theme\n * block, which resolves to the browser serif, and the timeline is dense\n * enough that the wrong face is the first thing you notice. Override\n * `--lifeline-font` to typeset it in something else.\n */\nexport function Lifeline(props: LifelineProps) {\n  const [isMobile, setIsMobile] = useState<boolean | null>(null)\n\n  useLayoutEffect(() => {\n    // Matches Tailwind's md: breakpoint so JS and CSS can never disagree.\n    const query = window.matchMedia(\n      `(min-width: ${LIFELINE_MOBILE_BREAKPOINT}px)`,\n    )\n    const update = () => setIsMobile(!query.matches)\n\n    update()\n    query.addEventListener(\"change\", update)\n    return () => query.removeEventListener(\"change\", update)\n  }, [])\n\n  if (isMobile === null) {\n    return <div className=\"invisible h-full\" aria-hidden=\"true\" />\n  }\n\n  if (isMobile) {\n    return (\n      <LifelineFireworksProvider>\n        {/*\n          Embedded, the vertical timeline gets its own bounded scroller:\n          the consumer's height lands here, and this element becomes the\n          scroll parent the vertical hook looks for. Native overscroll\n          chaining then releases to the page at either end, which is\n          exactly the embed contract. Page mode is left alone — the host's\n          own scroller owns it there, and `h-full` would only fight it.\n        */}\n        <div\n          className={\n            props.mode === \"embed\"\n              ? cn(\n                  \"lifeline-typeset h-full overflow-y-auto pt-5\",\n                  props.className,\n                )\n              : \"lifeline-typeset pt-5\"\n          }\n        >\n          <LifelineVertical {...props} />\n        </div>\n      </LifelineFireworksProvider>\n    )\n  }\n\n  return (\n    <LifelineFireworksProvider>\n      <LifelineDesktop\n        {...props}\n        className={cn(\"lifeline-typeset pt-5\", props.className)}\n      />\n    </LifelineFireworksProvider>\n  )\n}",
      "type": "registry:component",
      "target": "components/lifeline/lifeline.tsx"
    },
    {
      "path": "components/lifeline/types.ts",
      "content": "import type { CompanyIconId } from \"./company-icon\"\n\n\nexport interface LifelineMentor {\n  name: string\n  role?: string\n  color?: string\n  photo?: string\n}\n\nexport interface LifelineMetPerson {\n  name: string\n  photo?: string\n}\n\nexport interface LifelineCompany {\n  id: CompanyIconId\n  name: string\n}\n\nexport type LifelineEventSegment =\n  | { type: \"text\"; value: string }\n  | { type: \"link\"; value: string; href: string }\n\nexport interface LifelineEventImage {\n  src: string\n  alt: string\n  /** Optional mp4/webm — hover shows this (muted, looping) with src as fallback. */\n  video?: string\n}\n\n/**\n * An always-visible photo/video card that floats over the timeline,\n * anchored to its marker's position and scrolling with the track —\n * scattered, tilted, and draggable, like photos loose in a notebook.\n */\nexport interface LifelinePhoto extends LifelineEventImage {\n  /** 0..1 across the marker's slot; defaults to a seeded scatter. */\n  x?: number\n  /** Pixels below the rail; defaults to a seeded scatter. */\n  y?: number\n  /** Degrees; defaults to a seeded tilt. */\n  rotate?: number\n  /** Card width in pixels. Default 180. */\n  width?: number\n}\n\nexport type LifelineEventEffect = \"fireworks\" | \"fireworks-argentina\"\n\n/**\n * Object form lets an event carry a cursor-following hover image\n * and/or a click-triggered easter egg effect.\n */\nexport interface LifelineEventObject {\n  text: string | LifelineEventSegment[]\n  image?: LifelineEventImage\n  effect?: LifelineEventEffect\n}\n\nexport type LifelineEvent =\n  | string\n  | LifelineEventSegment[]\n  | LifelineEventObject\n\nexport interface LifelineMarker {\n  id: string\n  /** Position on the numeric axis — a year, or any sequential unit (e.g. tournament day). */\n  year: number\n  /** Shown above the label; defaults to year - birthYear. Strings allowed for round tags etc. */\n  age?: number | string\n  /** Shown in place of the raw year — e.g. \"Jun 16\" on a day-based timeline. */\n  label?: string\n  events: LifelineEvent[]\n  /** Small emblems (team shields etc.) rendered above the events. */\n  badges?: { src: string; alt: string }[]\n  /** Floating media cards anchored to this marker's stretch of the timeline. */\n  photos?: LifelinePhoto[]\n  companies?: LifelineCompany[]\n  mentors?: LifelineMentor[]\n  met?: LifelineMetPerson[]\n}\n\n/**\n * A legend entry maps one of the two people slots to a subject-appropriate\n * label — \"Mentors\" for a person, \"Presidents\" for a nation.\n */\nexport interface LifelineLegendItem {\n  type: \"mentor\" | \"met\"\n  label: string\n}\n\n/**\n * How the timeline relates to the page around it.\n *\n * - `\"page\"` — the Lifeline *is* the page. It owns the wheel and the\n *   arrow keys, and nothing scrolls behind it.\n * - `\"embed\"` — the Lifeline is one module in a scrolling page. Wheel\n *   over it scrubs the rail, and at either end of the rail the wheel is\n *   handed back so the page carries on scrolling.\n * - `\"auto\"` — measured at runtime: page mode only when the timeline\n *   covers most of the viewport *and* there is nothing behind it left to\n *   scroll. Anything else is treated as embedded.\n */\nexport type LifelineMode = \"auto\" | \"page\" | \"embed\"\n\nexport interface LifelineProps {\n  markers: LifelineMarker[]\n  birthYear: number\n  className?: string\n  title?: string\n  mode?: LifelineMode\n}",
      "type": "registry:component",
      "target": "components/lifeline/types.ts"
    },
    {
      "path": "components/lifeline/use-lifeline-intro.ts",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport {\n  getTransitionMarkerFadeDuration,\n  LIFELINE_FAST_MARKER_FADE_MS,\n  timeAtTrackProgress,\n  trackProgressAtTime,\n} from \"./lifeline-intro-timing\"\n\n/** Tweak these */\nexport const LIFELINE_LABELS_MS = 600\nexport const LIFELINE_RAIL_MS = 3200\n/**\n * Track length (px on desktop, tall lifelines on mobile) the base rail\n * duration was tuned for — roughly a 40-year personal lifeline. Longer\n * tracks slow the sweep sublinearly so dense timelines stay readable,\n * capped so a 250-year nation doesn't become a screensaver.\n */\nexport const LIFELINE_REFERENCE_TRACK = 9000\nexport const LIFELINE_RAIL_MAX_MS = 7200\nexport const LIFELINE_RAIL_SCALE_POWER = 0.45\n/**\n * Keep fade stretching subtle — long fades lag behind the sweeping\n * line and read as out of sync.\n */\nexport const LIFELINE_FADE_SCALE_MAX = 1.5\n\nexport function useLifelineIntro(markerWidths: number[]) {\n  // Skip straight to the settled end state for users who prefer reduced motion.\n  const [shouldPlay] = useState(\n    () =>\n      typeof window === \"undefined\" ||\n      !window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n  )\n  const [isPlaying, setIsPlaying] = useState(true)\n  const [isComplete, setIsComplete] = useState(false)\n  const introTimeoutRef = useRef(0)\n\n  const totalMarkersWidth = useMemo(\n    () => markerWidths.reduce((sum, width) => sum + width, 0),\n    [markerWidths],\n  )\n\n  const railDuration = useMemo(() => {\n    if (totalMarkersWidth <= LIFELINE_REFERENCE_TRACK) return LIFELINE_RAIL_MS\n\n    const scale = Math.pow(\n      totalMarkersWidth / LIFELINE_REFERENCE_TRACK,\n      LIFELINE_RAIL_SCALE_POWER,\n    )\n    return Math.min(LIFELINE_RAIL_MAX_MS, Math.round(LIFELINE_RAIL_MS * scale))\n  }, [totalMarkersWidth])\n\n  // Stretch each marker's fade with the sweep so dense timelines bloom\n  // in a trailing wave instead of flickering past.\n  const fadeScale = Math.min(\n    LIFELINE_FADE_SCALE_MAX,\n    railDuration / LIFELINE_RAIL_MS,\n  )\n\n  const introDuration =\n    railDuration + Math.round(LIFELINE_FAST_MARKER_FADE_MS * fadeScale)\n\n  const getTrackProgressAtTime = useCallback(\n    (elapsedMs: number) => {\n      if (!shouldPlay || totalMarkersWidth <= 0) {\n        return Math.min(elapsedMs / railDuration, 1)\n      }\n\n      return trackProgressAtTime(elapsedMs, markerWidths, railDuration)\n    },\n    [markerWidths, railDuration, shouldPlay, totalMarkersWidth],\n  )\n\n  const getMarkerDelay = useCallback(\n    (index: number) => {\n      if (!shouldPlay || totalMarkersWidth <= 0) return 0\n\n      const offset = markerWidths\n        .slice(0, index)\n        .reduce((sum, width) => sum + width, 0)\n\n      return timeAtTrackProgress(\n        offset / totalMarkersWidth,\n        markerWidths,\n        railDuration,\n      )\n    },\n    [markerWidths, railDuration, shouldPlay, totalMarkersWidth],\n  )\n\n  const getMarkerFadeDuration = useCallback(\n    (index: number) => {\n      return Math.round(getTransitionMarkerFadeDuration(index) * fadeScale)\n    },\n    [fadeScale],\n  )\n\n  const completeIntro = useCallback(() => {\n    setIsComplete(true)\n  }, [])\n\n  const startIntroTimer = useCallback(() => {\n    window.clearTimeout(introTimeoutRef.current)\n    setIsPlaying(true)\n    setIsComplete(false)\n\n    introTimeoutRef.current = window.setTimeout(() => {\n      setIsPlaying(false)\n    }, introDuration)\n  }, [introDuration])\n\n  useEffect(() => {\n    return () => window.clearTimeout(introTimeoutRef.current)\n  }, [])\n\n  return {\n    shouldPlay,\n    isPlaying,\n    isComplete,\n    labelsDuration: LIFELINE_LABELS_MS,\n    railDuration,\n    getTrackProgressAtTime,\n    getMarkerDelay,\n    getMarkerFadeDuration,\n    startIntroTimer,\n    completeIntro,\n  }\n}",
      "type": "registry:component",
      "target": "components/lifeline/use-lifeline-intro.ts"
    },
    {
      "path": "components/lifeline/use-lifeline-scroll.ts",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\"\nimport {\n  LIFELINE_STICKY_LEFT,\n  LIFELINE_STICKY_SHIELD_WIDTH,\n} from \"./lifeline-labels\"\nimport { clamp } from \"./lifeline-utils\"\nimport type { LifelineMode } from \"./types\"\n\nconst FADE_ZONE = 200\nconst FADE_ZONE_COARSE = 72\nconst LEFT_EXIT_FADE_ZONE = 400\nconst LEFT_EXIT_FADE_ZONE_COARSE = 160\nconst WHEEL_SPEED = 1.4\nconst WHEEL_VELOCITY_FRAME_MS = 16.67\nconst WHEEL_MOMENTUM_BLEND = 0.65\nconst DRAG_SPEED = 1\nconst TOUCH_DRAG_SPEED = 1.15\nconst TOUCH_GESTURE_LOCK_PX = 8\nconst NAV_HORIZONTAL_PADDING = 24\nconst MOMENTUM_FRICTION = 0.94\nconst MOMENTUM_MIN_VELOCITY = 0.025\nconst MOMENTUM_MIN_START = 0.08\n\n/** Where the track starts when there is no host nav to align with. */\nconst LIFELINE_DEFAULT_START_INSET = 24\n\n/** Page mode needs the stage to cover at least this much of the viewport. */\nconst PAGE_MODE_VIEWPORT_COVERAGE = 0.5\n/** Scroll heights within this many px of the client height aren't scrollable. */\nconst PAGE_MODE_SCROLL_SLOP = 4\n/** How long a resolved mode is trusted before it is measured again. */\nconst MODE_RESOLVE_STALE_MS = 250\n\n/** A gap this long in the wheel stream ends one gesture and starts the next. */\nconst WHEEL_GESTURE_QUIET_MS = 120\n/**\n * Embedded: once the rail bottoms out, the wheel keeps being swallowed\n * until the stream has been quiet this long. Trackpad inertia arrives as\n * one unbroken stream, so a fast flick that eats the last of the rail\n * dies here instead of spilling into the page behind it.\n */\nconst EMBED_BOUNDARY_QUIET_MS = 260\n/**\n * …but never hold longer than this. Inertia decays; a finger or a wheel\n * that is still going does not, so a sustained scroll gets through.\n */\nconst EMBED_BOUNDARY_MAX_HOLD_MS = 900\n\n/**\n * How early the sweep is armed, relative to the module entering view. A\n * couple of hundred pixels means it is already moving by the time it is\n * properly on screen, rather than starting cold under the reader's eyes.\n */\nconst EMBED_INTRO_ARM_MARGIN = \"0px 0px 200px 0px\"\n\nfunction normalizeWheelDelta(event: WheelEvent) {\n  let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)\n    ? event.deltaX\n    : event.deltaY\n\n  if (event.deltaMode === 1) delta *= 16\n  if (event.deltaMode === 2) delta *= window.innerHeight\n\n  return delta\n}\n\nfunction isInteractiveTarget(target: EventTarget | null) {\n  // Pointer capture during drag retargets clicks to the section, so\n  // anything clickable must opt out of drag-start here.\n  return (\n    target instanceof Element &&\n    Boolean(target.closest(\"a, button, [data-lifeline-interactive]\"))\n  )\n}\n\nfunction isEditableTarget(target: EventTarget | null) {\n  return (\n    target instanceof Element &&\n    Boolean(target.closest(\"input, textarea, select, [contenteditable]\"))\n  )\n}\n\n/** How much of the viewport this element currently covers, 0..1. */\nfunction getViewportCoverage(element: HTMLElement) {\n  const rect = element.getBoundingClientRect()\n  const visibleX =\n    Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0)\n  const visibleY =\n    Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)\n  if (visibleX <= 0 || visibleY <= 0) return 0\n\n  return (visibleX * visibleY) / (window.innerWidth * window.innerHeight)\n}\n\n/**\n * Is there a vertical scroll behind this element for a released wheel to\n * drive? Walks out to the document, stopping at the first ancestor that\n * clips — a `LifelineShell` is `overflow-hidden`, so nothing escapes it.\n */\nfunction hasReleasableScroll(section: HTMLElement) {\n  let node = section.parentElement\n\n  while (node) {\n    const { overflowY } = window.getComputedStyle(node)\n\n    if (overflowY === \"hidden\" || overflowY === \"clip\") return false\n\n    if (\n      (overflowY === \"auto\" || overflowY === \"scroll\" || overflowY === \"overlay\") &&\n      node.scrollHeight > node.clientHeight + PAGE_MODE_SCROLL_SLOP\n    ) {\n      return true\n    }\n\n    node = node.parentElement\n  }\n\n  const doc = document.scrollingElement\n  return Boolean(doc && doc.scrollHeight > doc.clientHeight + PAGE_MODE_SCROLL_SLOP)\n}\n\ninterface LifelineScrollOptions {\n  mode?: LifelineMode\n  isCoarsePointer?: boolean\n  introLocked?: boolean\n  introAnimating?: boolean\n  introSkipped?: boolean\n  introRailMs?: number\n  introGetTrackProgress?: (elapsedMs: number) => number\n  onIntroSettleComplete?: () => void\n  onIntroScrollStart?: () => void\n}\n\nexport function useLifelineScroll(\n  markerCount: number,\n  options: LifelineScrollOptions = {},\n) {\n  const sectionRef = useRef<HTMLDivElement>(null)\n  const trackRef = useRef<HTMLDivElement>(null)\n  const labelsRef = useRef<HTMLDivElement>(null)\n  const markerRefs = useRef<(HTMLDivElement | null)[]>([])\n  const maxTranslate = useRef(0)\n  const startInset = useRef(LIFELINE_DEFAULT_START_INSET)\n  const endInset = useRef(0)\n  const translatePx = useRef(0)\n  const initialized = useRef(false)\n  const dragging = useRef(false)\n  const gestureAxis = useRef<\"x\" | \"y\" | null>(null)\n  const gestureStart = useRef({ x: 0, y: 0 })\n  const dragOrigin = useRef({ x: 0, translate: 0 })\n  const dragVelocity = useRef(0)\n  const lastPointerSample = useRef({ x: 0, t: 0 })\n  const activePointerId = useRef<number | null>(null)\n  const isCoarsePointerRef = useRef(options.isCoarsePointer ?? false)\n  const momentumId = useRef(0)\n  const settleId = useRef(0)\n  const introLockedRef = useRef(options.introLocked ?? false)\n  const introAnimatingRef = useRef(options.introAnimating ?? false)\n  const introSkippedRef = useRef(options.introSkipped ?? false)\n  const onIntroSettleCompleteRef = useRef(options.onIntroSettleComplete)\n  const onIntroScrollStartRef = useRef(options.onIntroScrollStart)\n  const settlingRef = useRef(false)\n  const introWasAnimatingRef = useRef(false)\n  const introScrollId = useRef(0)\n  const introScrollStart = useRef(0)\n  const introStartedRef = useRef(false)\n  const introGetTrackProgressRef = useRef(options.introGetTrackProgress)\n  const applyTranslateRef = useRef<(value: number) => void>(() => {})\n  const scheduleMeasureRef = useRef<() => void>(() => {})\n  const modeRef = useRef<LifelineMode>(options.mode ?? \"auto\")\n  const isEmbedRef = useRef(false)\n  const modeResolvedAt = useRef(0)\n  const prefersReducedMotionRef = useRef(false)\n  const wheelStreamAt = useRef(0)\n  const wheelGapMs = useRef(Number.POSITIVE_INFINITY)\n  const gestureStartedHere = useRef(false)\n  const gestureReleased = useRef(false)\n  const boundaryHitAt = useRef(0)\n  const [isLayoutReady, setIsLayoutReady] = useState(false)\n  const [isEmbed, setIsEmbed] = useState(false)\n  const [introArmed, setIntroArmed] = useState(false)\n\n  modeRef.current = options.mode ?? \"auto\"\n  isCoarsePointerRef.current = options.isCoarsePointer ?? false\n  introLockedRef.current = options.introLocked ?? false\n  introAnimatingRef.current = options.introAnimating ?? false\n  introSkippedRef.current = options.introSkipped ?? false\n  onIntroSettleCompleteRef.current = options.onIntroSettleComplete\n  onIntroScrollStartRef.current = options.onIntroScrollStart\n  introGetTrackProgressRef.current = options.introGetTrackProgress\n\n  const setMarkerRef = useCallback(\n    (index: number, node: HTMLDivElement | null) => {\n      markerRefs.current[index] = node\n\n      if (index === markerCount - 1 && node) {\n        scheduleMeasureRef.current()\n      }\n    },\n    [markerCount],\n  )\n\n  const getLabelStageLeft = useCallback((translate: number) => {\n    const section = sectionRef.current\n    if (!section) return { isSticky: false, labelLeft: 0 }\n\n    // Stage-relative: labels pin LIFELINE_STICKY_LEFT px inside the\n    // section's own left edge, wherever the section sits on the page.\n    const naturalLeft = startInset.current - translate\n    const isSticky = naturalLeft <= LIFELINE_STICKY_LEFT\n\n    return {\n      isSticky,\n      labelLeft: isSticky ? LIFELINE_STICKY_LEFT : naturalLeft,\n    }\n  }, [])\n\n  const applyLabelSticky = useCallback(\n    (translate: number) => {\n      const labels = labelsRef.current\n      const { isSticky, labelLeft } = getLabelStageLeft(translate)\n\n      if (!labels) return { isSticky, labelLeft }\n\n      if (isSticky) {\n        const labelExtra =\n          LIFELINE_STICKY_LEFT - startInset.current + translate\n        labels.style.transform = `translate3d(${labelExtra}px, 0, 0)`\n        labels.classList.add(\"is-pinned\")\n      } else {\n        labels.style.transform = \"\"\n        labels.classList.remove(\"is-pinned\")\n      }\n\n      return { isSticky, labelLeft }\n    },\n    [getLabelStageLeft],\n  )\n\n  const isScrollLocked = useCallback(() => {\n    return (\n      (introLockedRef.current && introStartedRef.current) ||\n      settlingRef.current\n    )\n  }, [])\n\n  const updateFades = useCallback(() => {\n    if (settlingRef.current) return\n\n    const section = sectionRef.current\n    if (!section) return\n\n    // All fade math is relative to the section's own box — the lifeline\n    // may be embedded anywhere, not pinned to the viewport.\n    const stageRect = section.getBoundingClientRect()\n    const isCoarse = isCoarsePointerRef.current\n    const fadeZone = isCoarse ? FADE_ZONE_COARSE : FADE_ZONE\n    const leftFadeZone = isCoarse\n      ? LEFT_EXIT_FADE_ZONE_COARSE\n      : LEFT_EXIT_FADE_ZONE\n\n    markerRefs.current.forEach((marker) => {\n      if (!marker) return\n\n      const rect = marker.getBoundingClientRect()\n      const markerLeft = rect.left - stageRect.left\n      const center = markerLeft + rect.width / 2\n\n      let opacity = 1\n\n      // Fade a marker out only as scrubbing carries it left of where\n      // it rests at translate 0 — the first markers naturally live\n      // inside the fade zone and must not open dimmed.\n      const naturalLeft = markerLeft + translatePx.current\n      const restLeft = Math.min(naturalLeft, leftFadeZone)\n      if (markerLeft < restLeft) {\n        opacity = markerLeft <= 0 ? 0 : markerLeft / restLeft\n      }\n\n      if (center > stageRect.width - fadeZone) {\n        opacity = Math.min(opacity, (stageRect.width - center) / fadeZone)\n      }\n\n      if (isCoarse) {\n        const readableLeft = LIFELINE_STICKY_SHIELD_WIDTH\n        const readableRight = stageRect.width - 12\n        const markerRight = rect.right - stageRect.left\n        const visibleWidth =\n          Math.min(markerRight, readableRight) - Math.max(markerLeft, readableLeft)\n        const visibility = rect.width > 0 ? visibleWidth / rect.width : 0\n\n        if (visibility >= 0.5) {\n          opacity = 1\n        }\n      }\n\n      marker.style.opacity = String(clamp(opacity, 0, 1))\n    })\n  }, [])\n\n  const applyTranslate = useCallback(\n    (value: number) => {\n      const max = maxTranslate.current\n      const next = clamp(value, 0, max)\n      translatePx.current = next\n\n      if (trackRef.current) {\n        trackRef.current.style.transform = `translate3d(${startInset.current - next}px, 0, 0)`\n      }\n\n      applyLabelSticky(next)\n      updateFades()\n    },\n    [applyLabelSticky, updateFades],\n  )\n\n  applyTranslateRef.current = applyTranslate\n\n  /**\n   * Page mode or embedded? An explicit `mode` decides it outright.\n   * `\"auto\"` measures: the timeline is the page only when it covers most\n   * of the viewport *and* there is nothing behind it left to scroll.\n   * Both halves matter — a full-bleed hero section on a long landing page\n   * covers the viewport but must still hand the wheel back at the ends.\n   *\n   * Cached for MODE_RESOLVE_STALE_MS because this reads layout and the\n   * wheel path calls it. `force` is for measure passes, which are already\n   * doing layout work.\n   */\n  const resolveMode = useCallback((force = false) => {\n    const section = sectionRef.current\n    if (!section) return isEmbedRef.current\n\n    const mode = modeRef.current\n\n    if (mode !== \"auto\") {\n      const embed = mode === \"embed\"\n      if (embed !== isEmbedRef.current) {\n        isEmbedRef.current = embed\n        setIsEmbed(embed)\n      }\n      return embed\n    }\n\n    const now = performance.now()\n    if (!force && now - modeResolvedAt.current < MODE_RESOLVE_STALE_MS) {\n      return isEmbedRef.current\n    }\n    modeResolvedAt.current = now\n\n    const isPage =\n      getViewportCoverage(section) >= PAGE_MODE_VIEWPORT_COVERAGE &&\n      !hasReleasableScroll(section)\n\n    if (isPage === !isEmbedRef.current) return isEmbedRef.current\n\n    isEmbedRef.current = !isPage\n    setIsEmbed(!isPage)\n    return isEmbedRef.current\n  }, [])\n\n  const measureLayout = useCallback(() => {\n    const track = trackRef.current\n    const section = sectionRef.current\n    if (!track || !section) return 0\n\n    const embed = resolveMode(true)\n    const stageRect = section.getBoundingClientRect()\n\n    const navLogo = document.querySelector(\"[data-site-nav-logo]\")\n    const navInner = document.querySelector(\"[data-site-nav-inner]\")\n\n    const logoLeft = navLogo\n      ? navLogo.getBoundingClientRect().left - stageRect.left\n      : null\n    const navRight = navInner\n      ? navInner.getBoundingClientRect().right -\n        stageRect.left -\n        NAV_HORIZONTAL_PADDING\n      : null\n\n    /**\n     * A full-page lifeline always follows the host chrome. An embedded one\n     * follows it only when the module actually spans it — a full-bleed\n     * module lines its rail up with the logo and the container's right edge,\n     * exactly as the full-page version does, while a timeline in a narrow\n     * card has nothing to align to a nav sitting outside its own box and\n     * measures itself instead.\n     */\n    const followChrome =\n      !embed ||\n      (logoLeft !== null &&\n        navRight !== null &&\n        logoLeft >= 0 &&\n        navRight <= stageRect.width &&\n        navRight > logoLeft)\n\n    if (followChrome && logoLeft !== null) {\n      startInset.current = logoLeft\n    } else {\n      startInset.current = LIFELINE_DEFAULT_START_INSET\n    }\n\n    if (followChrome && navRight !== null) {\n      endInset.current = navRight\n    } else {\n      // No chrome to align with, or none this module spans — end the track\n      // at the stage's own right edge instead.\n      endInset.current = stageRect.width - NAV_HORIZONTAL_PADDING\n    }\n\n    const lastMarker = markerRefs.current[markerCount - 1]\n    const lastMarkerRight = lastMarker\n      ? LIFELINE_STICKY_SHIELD_WIDTH +\n        lastMarker.offsetLeft +\n        lastMarker.offsetWidth\n      : track.scrollWidth\n\n    const max = Math.max(\n      0,\n      startInset.current + lastMarkerRight - endInset.current,\n    )\n    maxTranslate.current = max\n\n    return max\n  }, [markerCount, resolveMode])\n\n  useLayoutEffect(() => {\n    markerRefs.current.length = markerCount\n  }, [markerCount])\n\n  useLayoutEffect(() => {\n    // A timeline short enough to fit its stage measures max = 0. It still has\n    // to be shown — gating readiness on a scrollable track left any small\n    // timeline permanently `invisible`.\n    const max = measureLayout()\n\n    if (!initialized.current) {\n      // A skipped intro parks the rail where the intro would have settled\n      // it — its end, the present. Embedded is no different: it is the same\n      // intro and the same resting place.\n      translatePx.current = introSkippedRef.current ? max : 0\n      initialized.current = true\n    }\n\n    applyTranslate(translatePx.current)\n    setIsLayoutReady(true)\n    // Sync initial position once before first paint; resize uses measure().\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  /**\n   * A full-page lifeline opens as the page opens, so its intro needs no\n   * cue. An embedded one can be anywhere, including far below the fold —\n   * playing there would spend the sweep on nobody. So it waits until the\n   * module is about to come into view.\n   */\n  useEffect(() => {\n    if (!isEmbed) return\n\n    const section = sectionRef.current\n    if (!section || introArmed) return\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (!entries.some((entry) => entry.isIntersecting)) return\n        setIntroArmed(true)\n        observer.disconnect()\n      },\n      { rootMargin: EMBED_INTRO_ARM_MARGIN },\n    )\n\n    observer.observe(section)\n    return () => observer.disconnect()\n  }, [isEmbed, introArmed])\n\n  useEffect(() => {\n    if (!isLayoutReady) return\n    // Embedded, hold the sweep until the module is in view. `isLayoutReady`\n    // and `isEmbed` are set in the same commit, so by the time this runs for\n    // real the mode is already known and an embedded instance cannot slip\n    // through and start early.\n    if (isEmbed && !introArmed) return\n    if (options.introSkipped || !options.introAnimating) {\n      cancelAnimationFrame(introScrollId.current)\n      introScrollId.current = 0\n      introStartedRef.current = false\n      return\n    }\n\n    introWasAnimatingRef.current = true\n    const railMs = options.introRailMs ?? 3200\n\n    const step = (now: number) => {\n      const max = maxTranslate.current\n\n      if (max <= 0) {\n        // Nothing to travel: a timeline that fits its stage has no rail to\n        // sweep. Waiting for one spun this loop forever and left the intro\n        // lock on, so run the intro out where it already is — the markers\n        // and labels still get their fade, there is just no journey.\n        if (!introStartedRef.current) {\n          introStartedRef.current = true\n          introScrollStart.current = now\n          onIntroScrollStartRef.current?.()\n        }\n        sectionRef.current?.style.setProperty(\"--lifeline-intro-progress\", \"1\")\n        introScrollId.current = 0\n        return\n      }\n\n      if (!introStartedRef.current) {\n        introStartedRef.current = true\n        introScrollStart.current = now\n        onIntroScrollStartRef.current?.()\n        sectionRef.current?.style.setProperty(\"--lifeline-intro-progress\", \"0\")\n        applyTranslateRef.current(0)\n      }\n\n      const elapsed = now - introScrollStart.current\n      const progress = introGetTrackProgressRef.current\n        ? clamp(introGetTrackProgressRef.current(elapsed), 0, 1)\n        : clamp(elapsed / railMs, 0, 1)\n\n      sectionRef.current?.style.setProperty(\n        \"--lifeline-intro-progress\",\n        String(progress),\n      )\n      applyTranslateRef.current(progress * max)\n\n      if (progress < 1) {\n        introScrollId.current = requestAnimationFrame(step)\n        return\n      }\n\n      sectionRef.current?.style.setProperty(\"--lifeline-intro-progress\", \"1\")\n      applyTranslateRef.current(max)\n      introScrollId.current = 0\n    }\n\n    introScrollId.current = requestAnimationFrame(step)\n\n    return () => {\n      cancelAnimationFrame(introScrollId.current)\n      introScrollId.current = 0\n      introStartedRef.current = false\n    }\n  }, [\n    introArmed,\n    isEmbed,\n    isLayoutReady,\n    options.introAnimating,\n    options.introRailMs,\n    options.introSkipped,\n  ])\n\n  useEffect(() => {\n    if (options.introSkipped) return\n\n    if (options.introAnimating) return\n\n    if (!introWasAnimatingRef.current) return\n    introWasAnimatingRef.current = false\n\n    sectionRef.current?.style.removeProperty(\"--lifeline-intro-progress\")\n    markerRefs.current.forEach((marker) => {\n      if (marker) marker.style.opacity = \"\"\n    })\n    updateFades()\n    onIntroSettleCompleteRef.current?.()\n  }, [\n    options.introAnimating,\n    options.introSkipped,\n    updateFades,\n  ])\n\n  useEffect(() => {\n    return () => {\n      initialized.current = false\n    }\n  }, [])\n\n  useEffect(() => {\n    const section = sectionRef.current\n    if (!section) return\n\n    let frameId = 0\n    let resizeObserver: ResizeObserver | null = null\n\n    const motionQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const onMotionChange = () => {\n      prefersReducedMotionRef.current = motionQuery.matches\n    }\n    prefersReducedMotionRef.current = motionQuery.matches\n    motionQuery.addEventListener(\"change\", onMotionChange)\n\n    const stopMomentum = () => {\n      cancelAnimationFrame(momentumId.current)\n      momentumId.current = 0\n    }\n\n    const startMomentum = () => {\n      if (Math.abs(dragVelocity.current) < MOMENTUM_MIN_START) return\n\n      stopMomentum()\n      let lastFrameTime = performance.now()\n\n      const step = (now: number) => {\n        const dt = Math.min(now - lastFrameTime, 32)\n        lastFrameTime = now\n\n        const velocity = dragVelocity.current\n        if (Math.abs(velocity) < MOMENTUM_MIN_VELOCITY) {\n          dragVelocity.current = 0\n          momentumId.current = 0\n          return\n        }\n\n        const max = maxTranslate.current\n        const next = clamp(translatePx.current + velocity * dt, 0, max)\n\n        if (next !== translatePx.current) {\n          applyTranslate(next)\n        }\n\n        if (next <= 0 || next >= max) {\n          // Coasting into an end counts as hitting it, so the next wheel\n          // event starts its hold from the moment of contact rather than\n          // restarting the clock.\n          if (isEmbedRef.current && boundaryHitAt.current === 0) {\n            boundaryHitAt.current = performance.now()\n          }\n          dragVelocity.current = 0\n          momentumId.current = 0\n          return\n        }\n\n        dragVelocity.current =\n          velocity * Math.pow(MOMENTUM_FRICTION, dt / 16.67)\n        momentumId.current = requestAnimationFrame(step)\n      }\n\n      momentumId.current = requestAnimationFrame(step)\n    }\n\n    const measure = () => {\n      // max === 0 is a legitimate measurement — a timeline that fits — so it\n      // must not skip the ready flag either.\n      const max = measureLayout()\n\n      translatePx.current = clamp(translatePx.current, 0, max)\n\n      // During intro scroll, the rAF loop owns translate — only refresh bounds.\n      if (!(introAnimatingRef.current && introStartedRef.current)) {\n        applyTranslate(translatePx.current)\n      }\n\n      setIsLayoutReady(true)\n    }\n\n    const scheduleMeasure = () => {\n      cancelAnimationFrame(frameId)\n      frameId = requestAnimationFrame(measure)\n    }\n\n    scheduleMeasureRef.current = scheduleMeasure\n\n    scheduleMeasure()\n    frameId = requestAnimationFrame(() => {\n      measure()\n      requestAnimationFrame(measure)\n    })\n\n    resizeObserver = new ResizeObserver(scheduleMeasure)\n    if (trackRef.current) resizeObserver.observe(trackRef.current)\n\n    window.addEventListener(\"resize\", scheduleMeasure)\n\n    const scrub = (movement: number, target: number) => {\n      applyTranslate(target)\n\n      const impulse = (movement / WHEEL_VELOCITY_FRAME_MS) * 0.35\n      dragVelocity.current =\n        dragVelocity.current * (1 - WHEEL_MOMENTUM_BLEND) +\n        impulse * WHEEL_MOMENTUM_BLEND\n\n      // Embedded under reduced motion, skip the coast: inertia is what the\n      // preference asks you to drop, and it is also the one thing that\n      // makes the release decision non-deterministic.\n      if (isEmbedRef.current && prefersReducedMotionRef.current) return\n\n      if (momentumId.current === 0) {\n        startMomentum()\n      }\n    }\n\n    const release = () => {\n      gestureReleased.current = true\n      stopMomentum()\n      dragVelocity.current = 0\n    }\n\n    /**\n     * Segments the wheel stream into gestures, before the section's own\n     * handler sees the event — a capture listener on the window runs ahead\n     * of the target. The section alone cannot tell a gesture that started\n     * on it from a page-scroll gesture that merely arrived on it, because\n     * it only sees events once it is under the pointer.\n     */\n    const onWheelStream = (event: WheelEvent) => {\n      const now = performance.now()\n      wheelGapMs.current = now - wheelStreamAt.current\n      wheelStreamAt.current = now\n\n      if (wheelGapMs.current <= WHEEL_GESTURE_QUIET_MS) return\n\n      gestureStartedHere.current =\n        event.target instanceof Node && section.contains(event.target)\n      gestureReleased.current = false\n      boundaryHitAt.current = 0\n    }\n\n    const onWheel = (event: WheelEvent) => {\n      if (isScrollLocked()) return\n\n      if (maxTranslate.current <= 0) {\n        scheduleMeasure()\n        return\n      }\n\n      // A sideways swipe is never a request to scroll the page, so it\n      // scrubs at the rail ends too and never releases.\n      const horizontalIntent = Math.abs(event.deltaX) > Math.abs(event.deltaY)\n\n      const delta = normalizeWheelDelta(event)\n      /**\n       * The two axes are not the same gesture and do not share a sign.\n       *\n       * Vertical is page scrolling: the intro leaves the rail at the\n       * present, and scrolling down walks back through it — the same in both\n       * modes. Embedding changes where the wheel *goes* at the ends of the\n       * rail, not which way the rail travels.\n       *\n       * Horizontal is a drag by another name, so it has to match the pointer\n       * drag below: the rail follows the fingers. Swiping left (deltaX > 0)\n       * carries the timeline left, into the future, exactly as grabbing it\n       * and pulling left does.\n       */\n      const movement = (horizontalIntent ? delta : -delta) * WHEEL_SPEED\n\n      if (!resolveMode()) {\n        // Page mode: the lifeline is the page and every wheel is ours.\n        event.preventDefault()\n        scrub(movement, translatePx.current + movement)\n        return\n      }\n\n      if (!horizontalIntent) {\n        // A gesture already in flight when it reached us belongs to the\n        // page: the flick carries past, and the next scroll scrubs.\n        if (!gestureStartedHere.current) return\n        if (gestureReleased.current) return\n      }\n\n      const target = clamp(\n        translatePx.current + movement,\n        0,\n        maxTranslate.current,\n      )\n\n      if (target === translatePx.current && !horizontalIntent) {\n        // At an end of the rail, still being pushed further out. Hold the\n        // wheel until the stream goes quiet, then hand it to the page.\n        const now = performance.now()\n        if (boundaryHitAt.current === 0) boundaryHitAt.current = now\n\n        if (\n          wheelGapMs.current < EMBED_BOUNDARY_QUIET_MS &&\n          now - boundaryHitAt.current < EMBED_BOUNDARY_MAX_HOLD_MS\n        ) {\n          event.preventDefault()\n          stopMomentum()\n          dragVelocity.current = 0\n          return\n        }\n\n        release()\n        return\n      }\n\n      // Moving again — a reversal re-arms the hold at the other end.\n      boundaryHitAt.current = 0\n      event.preventDefault()\n      scrub(movement, target)\n    }\n\n    // The rail moves by transform; any native scroll on the section is the\n    // browser chasing a focused link deep in the track, and would leave the\n    // transform and the layout disagreeing about where the rail is.\n    const onSectionScroll = () => {\n      if (section.scrollLeft !== 0) section.scrollLeft = 0\n      if (section.scrollTop !== 0) section.scrollTop = 0\n    }\n\n    const beginDrag = (event: PointerEvent) => {\n      stopMomentum()\n      dragVelocity.current = 0\n      dragging.current = true\n      activePointerId.current = event.pointerId\n      dragOrigin.current = { x: event.clientX, translate: translatePx.current }\n      lastPointerSample.current = {\n        x: event.clientX,\n        t: performance.now(),\n      }\n\n      if (section.setPointerCapture) {\n        section.setPointerCapture(event.pointerId)\n      }\n\n      section.style.cursor = \"grabbing\"\n      section.style.touchAction = \"none\"\n    }\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (isScrollLocked()) return\n      if (isInteractiveTarget(event.target)) return\n      if (maxTranslate.current <= 0) return\n      if (activePointerId.current !== null) return\n\n      gestureAxis.current = null\n      gestureStart.current = { x: event.clientX, y: event.clientY }\n\n      if (event.pointerType === \"touch\") {\n        activePointerId.current = event.pointerId\n        return\n      }\n\n      beginDrag(event)\n    }\n\n    const onPointerMove = (event: PointerEvent) => {\n      if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {\n        return\n      }\n\n      if (!dragging.current && event.pointerType === \"touch\") {\n        const deltaX = event.clientX - gestureStart.current.x\n        const deltaY = event.clientY - gestureStart.current.y\n\n        if (gestureAxis.current === null) {\n          if (\n            Math.abs(deltaX) < TOUCH_GESTURE_LOCK_PX &&\n            Math.abs(deltaY) < TOUCH_GESTURE_LOCK_PX\n          ) {\n            return\n          }\n\n          gestureAxis.current =\n            Math.abs(deltaX) >= Math.abs(deltaY) ? \"x\" : \"y\"\n\n          if (gestureAxis.current === \"y\") {\n            activePointerId.current = null\n            return\n          }\n\n          beginDrag(event)\n        }\n      }\n\n      if (!dragging.current) return\n\n      if (event.pointerType === \"touch\") {\n        event.preventDefault()\n      }\n\n      const now = performance.now()\n      const sample = lastPointerSample.current\n      const elapsed = now - sample.t\n      const dragSpeed = event.pointerType === \"touch\" ? TOUCH_DRAG_SPEED : DRAG_SPEED\n\n      if (elapsed > 0 && elapsed < 80) {\n        const instantVelocity =\n          (-(event.clientX - sample.x) / elapsed) * dragSpeed\n        dragVelocity.current =\n          instantVelocity * 0.65 + dragVelocity.current * 0.35\n      }\n\n      lastPointerSample.current = { x: event.clientX, t: now }\n\n      const deltaX = event.clientX - dragOrigin.current.x\n      applyTranslate(dragOrigin.current.translate - deltaX * dragSpeed)\n    }\n\n    const endDrag = (event: PointerEvent) => {\n      if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {\n        return\n      }\n\n      const wasDragging = dragging.current\n\n      dragging.current = false\n      gestureAxis.current = null\n      activePointerId.current = null\n\n      if (section.hasPointerCapture(event.pointerId)) {\n        section.releasePointerCapture(event.pointerId)\n      }\n\n      section.style.cursor = \"\"\n      section.style.touchAction = \"\"\n\n      if (wasDragging) {\n        startMomentum()\n      }\n    }\n\n    // Arrow keys scrub only when the lifeline effectively is the page, or\n    // when focus is inside it. An embedded instance must not capture host\n    // keyboard scrolling until the reader has tabbed into it.\n    const ownsKeyboard = () => {\n      const active = document.activeElement\n      if (active && section.contains(active)) return true\n\n      return !resolveMode()\n    }\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (maxTranslate.current <= 0) return\n      if (isScrollLocked()) return\n      if (isEditableTarget(event.target)) return\n      if (!ownsKeyboard()) return\n\n      stopMomentum()\n      dragVelocity.current = 0\n\n      if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n        event.preventDefault()\n        applyTranslate(translatePx.current - maxTranslate.current * 0.05)\n      }\n\n      if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n        event.preventDefault()\n        applyTranslate(translatePx.current + maxTranslate.current * 0.05)\n      }\n    }\n\n    window.addEventListener(\"wheel\", onWheelStream, {\n      passive: true,\n      capture: true,\n    })\n    section.addEventListener(\"scroll\", onSectionScroll, { passive: true })\n    section.addEventListener(\"wheel\", onWheel, { passive: false })\n    section.addEventListener(\"pointerdown\", onPointerDown)\n    section.addEventListener(\"pointermove\", onPointerMove, { passive: false })\n    section.addEventListener(\"pointerup\", endDrag)\n    section.addEventListener(\"pointercancel\", endDrag)\n    window.addEventListener(\"keydown\", onKeyDown)\n\n    return () => {\n      cancelAnimationFrame(frameId)\n      stopMomentum()\n      cancelAnimationFrame(settleId.current)\n      settlingRef.current = false\n      resizeObserver?.disconnect()\n      motionQuery.removeEventListener(\"change\", onMotionChange)\n      window.removeEventListener(\"resize\", scheduleMeasure)\n      window.removeEventListener(\"wheel\", onWheelStream, { capture: true })\n      section.removeEventListener(\"scroll\", onSectionScroll)\n      section.removeEventListener(\"wheel\", onWheel)\n      section.removeEventListener(\"pointerdown\", onPointerDown)\n      section.removeEventListener(\"pointermove\", onPointerMove)\n      section.removeEventListener(\"pointerup\", endDrag)\n      section.removeEventListener(\"pointercancel\", endDrag)\n      window.removeEventListener(\"keydown\", onKeyDown)\n      dragging.current = false\n      gestureAxis.current = null\n      activePointerId.current = null\n      // A remount must not start out believing it is mid-release.\n      gestureStartedHere.current = false\n      gestureReleased.current = false\n      boundaryHitAt.current = 0\n      wheelGapMs.current = Number.POSITIVE_INFINITY\n      section.style.cursor = \"\"\n      section.style.touchAction = \"\"\n    }\n  }, [applyTranslate, isScrollLocked, markerCount, measureLayout, resolveMode])\n\n  return {\n    sectionRef,\n    trackRef,\n    labelsRef,\n    setMarkerRef,\n    isLayoutReady,\n    isEmbed,\n    /**\n     * Embedded only: whether the module has come into view and the intro is\n     * cleared to play. Page mode never waits, so this stays false there and\n     * callers should read it as `!isEmbed || introArmed`.\n     */\n    introArmed,\n  }\n}",
      "type": "registry:component",
      "target": "components/lifeline/use-lifeline-scroll.ts"
    },
    {
      "path": "components/lifeline/use-lifeline-vertical-scroll.ts",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\"\nimport { clamp } from \"./lifeline-utils\"\n\nfunction getScrollParent(element: HTMLElement | null): HTMLElement | null {\n  let node = element?.parentElement ?? null\n\n  while (node) {\n    const { overflowY } = window.getComputedStyle(node)\n    if (overflowY === \"auto\" || overflowY === \"scroll\" || overflowY === \"overlay\") {\n      return node\n    }\n    node = node.parentElement\n  }\n\n  // Nothing on the way up scrolls, so the document does — which is the\n  // ordinary case for a page-mode timeline in a page that just scrolls.\n  // Returning null here left the whole rail `invisible` with no error.\n  return (document.scrollingElement as HTMLElement | null) ?? null\n}\n\ninterface LifelineVerticalScrollOptions {\n  /**\n   * Embedded, the timeline opens at its start rather than where a skipped\n   * intro would have settled it — the reader is arriving at a module in a\n   * page, not returning to a timeline that already played.\n   */\n  isEmbed?: boolean\n  introLocked?: boolean\n  introAnimating?: boolean\n  introSkipped?: boolean\n  introRailMs?: number\n  introGetTrackProgress?: (elapsedMs: number) => number\n  onIntroSettleComplete?: () => void\n  onIntroScrollStart?: () => void\n}\n\nexport function useLifelineVerticalScroll(\n  markerCount: number,\n  options: LifelineVerticalScrollOptions = {},\n) {\n  const sectionRef = useRef<HTMLElement>(null)\n  const entryRefs = useRef<(HTMLLIElement | null)[]>([])\n  const maxScrollRef = useRef(0)\n  const scrollParentRef = useRef<HTMLElement | null>(null)\n  const initialized = useRef(false)\n  const introLockedRef = useRef(options.introLocked ?? false)\n  const introAnimatingRef = useRef(options.introAnimating ?? false)\n  const introSkippedRef = useRef(options.introSkipped ?? false)\n  const isEmbedRef = useRef(options.isEmbed ?? false)\n  const onIntroSettleCompleteRef = useRef(options.onIntroSettleComplete)\n  const onIntroScrollStartRef = useRef(options.onIntroScrollStart)\n  const introGetTrackProgressRef = useRef(options.introGetTrackProgress)\n  const introStartedRef = useRef(false)\n  const introScrollId = useRef(0)\n  const introScrollStart = useRef(0)\n  const introWasAnimatingRef = useRef(false)\n  const scheduleMeasureRef = useRef<() => void>(() => {})\n  const [isLayoutReady, setIsLayoutReady] = useState(false)\n\n  introLockedRef.current = options.introLocked ?? false\n  introAnimatingRef.current = options.introAnimating ?? false\n  introSkippedRef.current = options.introSkipped ?? false\n  isEmbedRef.current = options.isEmbed ?? false\n  onIntroSettleCompleteRef.current = options.onIntroSettleComplete\n  onIntroScrollStartRef.current = options.onIntroScrollStart\n  introGetTrackProgressRef.current = options.introGetTrackProgress\n\n  const setEntryRef = useCallback((index: number, node: HTMLLIElement | null) => {\n    entryRefs.current[index] = node\n\n    if (index === markerCount - 1 && node) {\n      scheduleMeasureRef.current()\n    }\n  }, [markerCount])\n\n  const applyScroll = useCallback((value: number) => {\n    const scrollParent = scrollParentRef.current\n    if (!scrollParent) return\n\n    scrollParent.scrollTop = clamp(value, 0, maxScrollRef.current)\n  }, [])\n\n  const measureLayout = useCallback(() => {\n    const section = sectionRef.current\n    if (!section) return 0\n\n    const scrollParent = getScrollParent(section)\n    scrollParentRef.current = scrollParent\n\n    if (!scrollParent) return 0\n\n    const heights = entryRefs.current.map((entry) => entry?.offsetHeight ?? 0)\n    if (heights.length < markerCount || heights.some((height) => height <= 0)) {\n      return 0\n    }\n\n    const max = Math.max(0, scrollParent.scrollHeight - scrollParent.clientHeight)\n    maxScrollRef.current = max\n\n    return max\n  }, [markerCount])\n\n  useLayoutEffect(() => {\n    entryRefs.current.length = markerCount\n  }, [markerCount])\n\n  useLayoutEffect(() => {\n    const max = measureLayout()\n\n    const scrollParent = scrollParentRef.current\n    if (!scrollParent) return\n\n    if (!initialized.current) {\n      scrollParent.scrollTop =\n        introSkippedRef.current && !isEmbedRef.current ? max : 0\n      initialized.current = true\n    }\n\n    setIsLayoutReady(entryRefs.current.every((entry) => Boolean(entry)))\n    // Sync initial position once before first paint; resize uses measure().\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  useEffect(() => {\n    if (!isLayoutReady) return\n    if (options.introSkipped || !options.introAnimating) {\n      cancelAnimationFrame(introScrollId.current)\n      introScrollId.current = 0\n      introStartedRef.current = false\n      return\n    }\n\n    introWasAnimatingRef.current = true\n    const railMs = options.introRailMs ?? 3200\n\n    const step = (now: number) => {\n      const max = maxScrollRef.current\n\n      if (!introStartedRef.current) {\n        introStartedRef.current = true\n        introScrollStart.current = now\n        onIntroScrollStartRef.current?.()\n        sectionRef.current?.style.setProperty(\"--lifeline-intro-progress\", \"0\")\n        if (max > 0) applyScroll(0)\n      }\n\n      const elapsed = now - introScrollStart.current\n      const progress = introGetTrackProgressRef.current\n        ? clamp(introGetTrackProgressRef.current(elapsed), 0, 1)\n        : clamp(elapsed / railMs, 0, 1)\n\n      sectionRef.current?.style.setProperty(\n        \"--lifeline-intro-progress\",\n        String(progress),\n      )\n\n      if (max > 0) {\n        applyScroll(progress * max)\n      }\n\n      if (progress < 1) {\n        introScrollId.current = requestAnimationFrame(step)\n        return\n      }\n\n      sectionRef.current?.style.setProperty(\"--lifeline-intro-progress\", \"1\")\n      if (max > 0) applyScroll(max)\n      introScrollId.current = 0\n    }\n\n    introScrollId.current = requestAnimationFrame(step)\n\n    return () => {\n      cancelAnimationFrame(introScrollId.current)\n      introScrollId.current = 0\n      introStartedRef.current = false\n    }\n  }, [\n    applyScroll,\n    isLayoutReady,\n    options.introAnimating,\n    options.introRailMs,\n    options.introSkipped,\n  ])\n\n  useEffect(() => {\n    if (options.introSkipped) return\n    if (options.introAnimating) return\n    if (!introWasAnimatingRef.current) return\n\n    introWasAnimatingRef.current = false\n    sectionRef.current?.style.removeProperty(\"--lifeline-intro-progress\")\n    onIntroSettleCompleteRef.current?.()\n  }, [options.introAnimating, options.introSkipped])\n\n  useEffect(() => {\n    const section = sectionRef.current\n    if (!section) return\n\n    let frameId = 0\n    let resizeObserver: ResizeObserver | null = null\n\n    const measure = () => {\n      measureLayout()\n\n      const scrollParent = scrollParentRef.current\n      if (!scrollParent) return\n\n      if (!(introAnimatingRef.current && introStartedRef.current)) {\n        scrollParent.scrollTop = clamp(\n          scrollParent.scrollTop,\n          0,\n          maxScrollRef.current,\n        )\n      }\n\n      setIsLayoutReady(\n        entryRefs.current.length === markerCount &&\n          entryRefs.current.every((entry) => Boolean(entry)),\n      )\n    }\n\n    const scheduleMeasure = () => {\n      cancelAnimationFrame(frameId)\n      frameId = requestAnimationFrame(measure)\n    }\n\n    scheduleMeasureRef.current = scheduleMeasure\n\n    scheduleMeasure()\n    frameId = requestAnimationFrame(() => {\n      measure()\n      requestAnimationFrame(measure)\n    })\n\n    resizeObserver = new ResizeObserver(scheduleMeasure)\n    resizeObserver.observe(section)\n\n    window.addEventListener(\"resize\", scheduleMeasure)\n\n    const isScrollLocked = () =>\n      introLockedRef.current && introStartedRef.current\n\n    const preventScroll = (event: Event) => {\n      if (!isScrollLocked()) return\n      event.preventDefault()\n    }\n\n    const scrollParent = getScrollParent(section)\n    scrollParentRef.current = scrollParent\n\n    scrollParent?.addEventListener(\"wheel\", preventScroll, { passive: false })\n    scrollParent?.addEventListener(\"touchmove\", preventScroll, { passive: false })\n\n    return () => {\n      cancelAnimationFrame(frameId)\n      resizeObserver?.disconnect()\n      window.removeEventListener(\"resize\", scheduleMeasure)\n      scrollParent?.removeEventListener(\"wheel\", preventScroll)\n      scrollParent?.removeEventListener(\"touchmove\", preventScroll)\n      initialized.current = false\n    }\n  }, [markerCount, measureLayout])\n\n  return {\n    sectionRef,\n    setEntryRef,\n    isLayoutReady,\n  }\n}",
      "type": "registry:component",
      "target": "components/lifeline/use-lifeline-vertical-scroll.ts"
    },
    {
      "path": "lib/lifeline-data.ts",
      "content": "import type {\n  LifelineEvent,\n  LifelineLegendItem,\n  LifelineMarker,\n} from \"@/components/lifeline/types\"\n\nexport const LIFELINE_CURRENT_YEAR = 2026\n\nexport type LifelineMilestone = Omit<LifelineMarker, \"year\">\nexport type LifelineMilestones = Record<number, LifelineMilestone>\n\nexport interface LifelineRecord {\n  slug: string\n  name: string\n  birthYear: number\n  /** Last year on the timeline. Omit for living people. */\n  endYear?: number\n  description: string\n  /** People-legend labels; defaults to Mentors / Met in person. */\n  legend?: LifelineLegendItem[]\n  markers: LifelineMarker[]\n}\n\ninterface DefineLifelineInput {\n  slug: string\n  name: string\n  birthYear: number\n  endYear?: number\n  description: string\n  legend?: LifelineLegendItem[]\n  milestones: LifelineMilestones\n}\n\n/**\n * Translated event texts keyed by year, aligned by index with the\n * source milestone's events. Only the text is swapped — images,\n * effects, mentors, and structure stay single-sourced.\n */\nexport type LifelineTextOverrides = Record<number, string[]>\n\nexport function localizeLifelineMarkers(\n  markers: LifelineMarker[],\n  texts: LifelineTextOverrides,\n): LifelineMarker[] {\n  return markers.map((marker) => {\n    const translated = texts[marker.year]\n    if (!translated) return marker\n\n    const events: LifelineEvent[] = marker.events.map((event, index) => {\n      const text = translated[index]\n      if (text === undefined) return event\n      if (typeof event === \"string\") return text\n      if (Array.isArray(event)) return event\n      return { ...event, text }\n    })\n\n    return { ...marker, events }\n  })\n}\n\nexport function defineLifeline(input: DefineLifelineInput): LifelineRecord {\n  const { milestones, ...record } = input\n  const lastYear = input.endYear ?? LIFELINE_CURRENT_YEAR\n  const markers: LifelineMarker[] = []\n\n  for (let year = input.birthYear; year <= lastYear; year++) {\n    const milestone = milestones[year]\n\n    markers.push(\n      milestone\n        ? { year, ...milestone }\n        : { id: `year-${year}`, year, events: [] },\n    )\n  }\n\n  return { ...record, markers }\n}\n",
      "type": "registry:lib",
      "target": "lib/lifeline-data.ts"
    }
  ],
  "css": {
    "@keyframes lifeline-rail-draw": {
      "from": {
        "transform": "scaleX(0)"
      },
      "to": {
        "transform": "scaleX(1)"
      }
    },
    "@keyframes lifeline-marker-in": {
      "from": {
        "opacity": "0",
        "transform": "translate3d(0, 6px, 0)"
      },
      "to": {
        "opacity": "1",
        "transform": "translate3d(0, 0, 0)"
      }
    },
    "@keyframes lifeline-labels-in": {
      "from": {
        "opacity": "0"
      },
      "to": {
        "opacity": "1"
      }
    },
    ".lifeline-rail-intro": {
      "transform": "scaleX(var(--lifeline-intro-progress, 0))",
      "transform-origin": "left center"
    },
    ".lifeline-rail-intro-vertical": {
      "transform": "scaleY(var(--lifeline-intro-progress, 0))",
      "transform-origin": "top center"
    },
    ".lifeline-marker-intro": {
      "opacity": "0",
      "animation": "lifeline-marker-in var(--lifeline-marker-fade-ms, 420ms) cubic-bezier(0.22, 1, 0.36, 1) forwards"
    },
    ".lifeline-labels-intro": {
      "opacity": "0",
      "animation": "lifeline-labels-in var(--lifeline-labels-ms, 600ms) cubic-bezier(0.22, 1, 0.36, 1) forwards"
    },
    ".lifeline-labels.is-pinned": {
      "z-index": "20"
    },
    ".lifeline-typeset": {
      "--lifeline-font": "var(--font-geist-sans, \"Geist\"), \"Geist Fallback\", ui-sans-serif, system-ui, sans-serif",
      "font-family": "var(--lifeline-font)"
    }
  },
  "docs": "Mount it with markers from defineLifeline (lib/lifeline-data.ts):\n\n  import { Lifeline } from \"@/components/lifeline\"\n  import { sodas } from \"@/lib/lifelines/sodas\"\n\n  export default function Page() {\n    return (\n      <main className=\"flex h-dvh flex-col\">\n        <div className=\"min-h-0 flex-1 overflow-y-auto md:overflow-hidden\">\n          <Lifeline markers={sodas.markers} birthYear={sodas.birthYear} mode=\"page\" />\n        </div>\n      </main>\n    )\n  }\n\nThe vertical (mobile) layout measures itself against the nearest ancestor whose overflow-y is auto or scroll, so give it one — the wrapper above does. Desktop scrubs sideways and wants no scroller of its own.\n\nTypography: the timeline typesets itself in Geist via .lifeline-typeset rather than inheriting font-sans. Load Geist in your root layout — import { GeistSans } from \"geist/font/sans\" and put GeistSans.variable on <html> — or set --lifeline-font to use your own face.",
  "type": "registry:block"
}