shadimage
uiception
blockportfolio-v3source: ok · hit

Portfolio v3

Scroll-scrubbed lifeline timeline for a fullstack developer — Jon Doe filler career with fictional companies. Drop images and videos into public/ slots. New projects only (shadcn init --template next).

npx shadcn add "https://uiception.com/r/portfolio-v3.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

https://uiception.com/r/block-media.json

Files47 · source cached 10/14/2026, 12:30:18 PM

registry/new-york/templates/portfolio-v3/app/layout.tsx
import type { Metadata } from "next"
import { GeistSans } from "geist/font/sans"
import type { ReactNode } from "react"

import { ThemeProvider } from "../components/theme-provider"

import "./globals.css"

export const metadata: Metadata = {
  title: "Jon Doe",
  description:
    "Fullstack developer — a scroll-scrubbed timeline of sites shipped and teams joined.",
}

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html
      lang="en"
      className={`h-full ${GeistSans.variable} antialiased`}
      suppressHydrationWarning
    >
      <body
        className={`${GeistSans.className} flex min-h-svh flex-col bg-background font-sans text-foreground`}
      >
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}
registry/new-york/templates/portfolio-v3/app/page.tsx
import { ClaireCompanyIcons } from "../components/company-icons"
import { Avatar } from "../components/avatar"
import { Lifeline, LifelineLegend } from "../components/lifeline"
import {
  LifelineFooter,
  LifelineNav,
  LifelineShell,
  LifelineStage,
} from "../components/lifeline-shell"
import { ThemeSwitcher } from "../components/theme-switcher"
import { getJonDoeLifeline } from "../lib/media"

export default function HomePage() {
  const life = getJonDoeLifeline()

  return (
    <LifelineShell className="lifeline-typeset">
      <LifelineNav
        logo={
          <span className="flex items-center gap-3">
            <Avatar />
            <span className="text-sm font-semibold tracking-tight text-foreground">
              {life.name}
            </span>
          </span>
        }
        logoLabel={life.name}
      >
        <ThemeSwitcher />
      </LifelineNav>

      <ClaireCompanyIcons />

      <LifelineStage>
        <Lifeline
          markers={life.markers}
          birthYear={life.birthYear}
          title={life.name}
          className="h-full"
        />
      </LifelineStage>

      <LifelineFooter containerClassName="justify-center">
        <LifelineLegend items={life.legend} />
      </LifelineFooter>
    </LifelineShell>
  )
}
registry/new-york/templates/portfolio-v3/components/avatar.tsx
import Image from "next/image"

import { resolveAvatarSrc } from "../lib/media"

export function Avatar({ className = "size-10" }: { className?: string }) {
  const src = resolveAvatarSrc()

  return (
    <Image
      src={src}
      alt=""
      width={40}
      height={40}
      unoptimized
      priority
      loading="eager"
      className={`shrink-0 rounded-full object-cover ${className}`}
    />
  )
}
registry/new-york/templates/portfolio-v3/components/company-icons.tsx
"use client"

import { registerCompanyIcons } from "./lifeline/company-icon"
import { CursorDark } from "./ui/svgs/cursorDark"
import { CursorLight } from "./ui/svgs/cursorLight"
import { NvidiaIconDark } from "./ui/svgs/nvidiaIconDark"
import { NvidiaIconLight } from "./ui/svgs/nvidiaIconLight"
import { Vercel } from "./ui/svgs/vercel"
import { VercelDark } from "./ui/svgs/vercelDark"

registerCompanyIcons({
  vercel: { icon: Vercel, darkIcon: VercelDark },
  cursor: { icon: CursorLight, darkIcon: CursorDark },
  nvidia: { icon: NvidiaIconLight, darkIcon: NvidiaIconDark },
})

export function ClaireCompanyIcons() {
  return null
}
registry/new-york/templates/portfolio-v3/components/lifeline-shell.tsx
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"

const CONTAINER = "mx-auto flex w-full max-w-5xl items-center px-6"

export function LifelineShell({
  children,
  className,
}: {
  children: ReactNode
  className?: string
}) {
  return (
    <div
      className={cn("flex h-dvh flex-col overflow-hidden bg-background text-foreground antialiased transition-colors duration-300",
        className,
      )}
    >
      {children}
    </div>
  )
}

export function LifelineNav({
  logo,
  logoHref = "/",
  logoLabel = "Home",
  children,
  className,
  containerClassName,
}: {
  logo: ReactNode
  logoHref?: string
  logoLabel?: string
  children?: ReactNode
  className?: string
  containerClassName?: string
}) {
  return (
    <nav
      className={cn("fixed inset-x-0 top-0 z-50 border-b border-border bg-background/80 backdrop-blur-xl transition-colors duration-300",
        className,
      )}
    >
      <div
        data-site-nav-inner
        className={cn(CONTAINER, "h-16 justify-between", containerClassName)}
      >
        <a
          href={logoHref}
          data-site-nav-logo
          aria-label={logoLabel}
          className="text-foreground transition-[color,opacity] duration-300 hover:opacity-70"
        >
          {logo}
        </a>

        {children ? (
          <div className="flex items-center gap-8">{children}</div>
        ) : null}
      </div>
    </nav>
  )
}

export function LifelineStage({
  children,
  className,
}: {
  children: ReactNode
  className?: string
}) {
  return (
    <main
      className={cn("flex-1 min-h-0 overflow-y-auto pt-16 md:overflow-hidden",
        className,
      )}
    >
      {children}
    </main>
  )
}

export function LifelineFooter({
  children,
  className,
  containerClassName,
}: {
  children?: ReactNode
  className?: string
  containerClassName?: string
}) {
  return (
    <footer
      className={cn("shrink-0 border-t border-border bg-background/95 backdrop-blur-sm transition-colors duration-300",
        className,
      )}
    >
      <div
        className={cn(
          CONTAINER,
          "h-16 justify-between gap-6",
          containerClassName,
        )}
      >
        {children}
      </div>
    </footer>
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/index.ts
export { Lifeline } from "./lifeline"
export { LifelineMarkerColumn } from "./lifeline-marker"
export { useLifelineScroll } from "./use-lifeline-scroll"
export { CompanyIcon } from "./company-icon"
export { LifelineLegend } from "./lifeline-legend"
export type { CompanyIconId } from "./company-icon"

export type {
  LifelineCompany,
  LifelineEvent,
  LifelineEventCategory,
  LifelineEventClip,
  LifelineEventImage,
  LifelineEventSegment,
  LifelineGlobalEvent,
  LifelineLegendItem,
  LifelineMarker,
  LifelineMode,
  LifelineProps,
} from "./types"
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline.tsx
"use client"

import { useLayoutEffect, useState } from "react"
import { cn } from "@/lib/utils"

import { LifelineDesktop } from "./lifeline-desktop"
import { LifelineVertical } from "./lifeline-vertical"
import { LIFELINE_MOBILE_BREAKPOINT } from "./lifeline-layout"
import type { LifelineProps } from "./types"
import "./lifeline.css"

export function Lifeline(props: LifelineProps) {
  const [isMobile, setIsMobile] = useState<boolean | null>(null)

  useLayoutEffect(() => {
    const query = window.matchMedia(
      `(min-width: ${LIFELINE_MOBILE_BREAKPOINT}px)`,
    )
    const update = () => setIsMobile(!query.matches)

    update()
    query.addEventListener("change", update)
    return () => query.removeEventListener("change", update)
  }, [])

  if (isMobile === null) {
    return <div className="invisible h-full" aria-hidden="true" />
  }

  if (isMobile) {
    return (
      <div
        className={
          props.mode === "embed"
            ? cn(
                "lifeline-typeset h-full overflow-y-auto pt-5",
                props.className,
              )
            : "lifeline-typeset pt-5"
        }
      >
        <LifelineVertical {...props} />
      </div>
    )
  }

  return (
    <LifelineDesktop
      {...props}
      className={cn("lifeline-typeset py-5", props.className)}
    />
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/company-icon.tsx
import type { ComponentType } from "react"
import { cn } from "@/lib/utils"

export type CompanyIconId = string

export interface CompanyIconEntry {
  icon: ComponentType<{ className?: string }>
  darkIcon?: ComponentType<{ className?: string }>
  sizeClassName?: string
}

const registry: Record<string, CompanyIconEntry> = {}

export function registerCompanyIcons(
  entries: Record<string, CompanyIconEntry>,
) {
  Object.assign(registry, entries)
}

export function CompanyIcon({
  id,
  label,
  className,
}: {
  id: CompanyIconId
  label: string
  className?: string
}) {
  const entry = registry[id]

  if (entry) {
    const Icon = entry.icon
    const DarkIcon = entry.darkIcon
    return (
      <span
        className={cn("inline-flex shrink-0 items-center justify-center text-foreground transition-colors duration-300",
          entry.sizeClassName ?? "h-4 w-4",
          className,
        )}
        aria-label={label}
        title={label}
      >
        {DarkIcon ? (
          <>
            <Icon className="h-full w-full dark:hidden" aria-hidden="true" />
            <DarkIcon
              className="hidden h-full w-full dark:block"
              aria-hidden="true"
            />
          </>
        ) : (
          <Icon className="h-full w-full" aria-hidden="true" />
        )}
      </span>
    )
  }

  return (
    <span
      title={label}
      aria-label={label}
      className={cn("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",
        className,
      )}
    >
      {label.charAt(0)}
    </span>
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-desktop.tsx
"use client"

import { useMemo, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
import {
  LifelineStickyLabels,
  LIFELINE_STICKY_SHIELD_WIDTH,
} from "./lifeline-labels"
import { LifelineMarkerColumn } from "./lifeline-marker"
import type { LifelineEventImage, LifelineProps } from "./types"
import { getLifelineEventImage } from "./lifeline-event"
import { LifelineHoverImageProvider } from "./lifeline-hover-image"
import { useLifelineIntro } from "./use-lifeline-intro"
import { useLifelineScroll } from "./use-lifeline-scroll"
import { getMarkerWidth } from "./lifeline-utils"

export function LifelineDesktop({
  markers,
  birthYear,
  className,
  title = "Lifeline",
  mode = "auto",
}: LifelineProps) {
  const widths = useMemo(
    () =>
      markers.map((marker, index) =>
        getMarkerWidth(marker, markers[index + 1]?.year),
      ),
    [markers],
  )

  const hoverImages = useMemo(() => {
    const images: LifelineEventImage[] = []
    for (const marker of markers) {
      for (const event of marker.events) {
        const image = getLifelineEventImage(event)
        if (image) images.push(image)
      }
    }
    return images
  }, [markers])

  const intro = useLifelineIntro(widths)
  const isIntroAnimating = intro.shouldPlay && intro.isPlaying

  const {
    sectionRef,
    trackRef,
    labelsRef,
    setMarkerRef,
    isLayoutReady,
    isEmbed,
    introArmed,
  } = useLifelineScroll(markers.length, {
    mode,
    introLocked: isIntroAnimating,
    introAnimating: isIntroAnimating,
    introSkipped: !intro.shouldPlay,
    introRailMs: intro.railDuration,
    introGetTrackProgress: intro.getTrackProgressAtTime,
    onIntroScrollStart: intro.startIntroTimer,
    onIntroSettleComplete: intro.completeIntro,
  })

  const introWaitingInView = isEmbed && intro.shouldPlay && !introArmed
  const showIntro = isIntroAnimating && isLayoutReady && !introWaitingInView

  const trackWidth =
    LIFELINE_STICKY_SHIELD_WIDTH + widths.reduce((sum, width) => sum + width, 0)

  const introStyle = {
    "--lifeline-labels-ms": `${intro.labelsDuration}ms`,
    "--lifeline-rail-ms": `${intro.railDuration}ms`,
  } as CSSProperties

  return (
    <section
      ref={sectionRef}
      data-lifeline-mode={isEmbed ? "embed" : "page"}
      tabIndex={isEmbed ? 0 : undefined}
      className={cn("relative h-full min-h-0 select-none overflow-hidden [&_a]:cursor-pointer",
        isEmbed &&
          "touch-pan-y focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
        (!isLayoutReady || introWaitingInView) && "invisible",
        className,
      )}
      aria-label={title}
      style={showIntro ? introStyle : undefined}
    >
      <LifelineHoverImageProvider preload={hoverImages}>
      <div
        className="flex h-full items-center overflow-hidden"
        style={{ alignItems: "safe center" }}
      >
        <div
          ref={trackRef}
          className="relative flex w-max items-start will-change-transform [--lifeline-global-events-top:calc(14.5rem+40px)] [--lifeline-rail:5rem]"
          style={{ width: trackWidth }}
        >
          <div
            ref={labelsRef}
            className="lifeline-labels relative shrink-0 will-change-transform"
            style={{ width: LIFELINE_STICKY_SHIELD_WIDTH }}
          >
            {/* Opaque backdrop for the pinned labels. Reaches past the left
                edge so scrolled-away content never peeks through the gutter,
                and dissolves on its right edge so markers slide behind it
                instead of hitting a hard box seam. */}
            <div
              aria-hidden="true"
              className="lifeline-labels-shield pointer-events-none absolute inset-y-0 -left-16 right-0 bg-background transition-colors duration-300"
            />
            <div className={cn("relative", showIntro && "lifeline-labels-intro")}>
              <LifelineStickyLabels />
            </div>
          </div>

          <div className="relative">
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-x-0 top-(--lifeline-rail) h-px overflow-hidden"
            >
              <div
                className={cn("h-px w-full border-t border-dashed border-border transition-colors duration-300",
                  showIntro && "lifeline-rail-intro",
                )}
              />
            </div>

            <div className="relative flex items-start">
              {markers.map((marker, index) => (
                <LifelineMarkerColumn
                  key={marker.id}
                  ref={(node) => setMarkerRef(index, node)}
                  marker={marker}
                  birthYear={birthYear}
                  minWidth={widths[index]}
                  animateIntro={showIntro}
                  introDelay={intro.getMarkerDelay(index)}
                  introDuration={intro.getMarkerFadeDuration(index)}
                />
              ))}
            </div>
          </div>
        </div>
      </div>

      </LifelineHoverImageProvider>
    </section>
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-event.tsx
import type {
  LifelineEvent,
  LifelineEventCategory,
  LifelineEventClip,
  LifelineEventImage,
  LifelineEventSegment,
} from "./types"

export const LIFELINE_EVENT_CATEGORY_DOT_CLASS: Record<
  LifelineEventCategory,
  string
> = {
  work: "bg-amber-500",
  college: "bg-violet-500",
  destination: "bg-teal-500",
}

function getEventContent(
  event: LifelineEvent,
): string | LifelineEventSegment[] {
  if (typeof event === "object" && !Array.isArray(event) && "text" in event) {
    return event.text
  }

  return event
}

export function getLifelineEventImage(
  event: LifelineEvent,
): LifelineEventImage | undefined {
  if (typeof event === "object" && !Array.isArray(event) && "image" in event) {
    return event.image
  }

  return undefined
}

export function getLifelineEventVideo(
  event: LifelineEvent,
): LifelineEventClip | undefined {
  if (typeof event === "object" && !Array.isArray(event) && "video" in event) {
    return event.video
  }

  return undefined
}

export function getLifelineEventCategory(
  event: LifelineEvent,
): LifelineEventCategory | undefined {
  if (
    typeof event === "object" &&
    !Array.isArray(event) &&
    "category" in event
  ) {
    return event.category
  }

  return undefined
}

export function LifelineEventText({
  event,
  className,
}: {
  event: LifelineEvent
  className?: string
}) {
  const content = getEventContent(event)

  if (typeof content === "string") {
    return <span className={className}>{content}</span>
  }

  return (
    <span className={className}>
      {content.map((segment, index) =>
        segment.type === "link" ? (
          <a
            key={index}
            href={segment.href}
            target="_blank"
            rel="noopener noreferrer"
            className="underline decoration-muted-foreground underline-offset-2 transition-colors duration-300 group-hover:text-foreground group-hover:decoration-foreground"
          >
            {segment.value}
          </a>
        ) : (
          <span key={index}>{segment.value}</span>
        ),
      )}
    </span>
  )
}

export function LifelineEventMedia({
  media,
  className,
}: {
  media: LifelineEventImage
  className?: string
}) {
  return (
    // eslint-disable-next-line @next/next/no-img-element
    <img src={media.src} alt={media.alt} loading="lazy" className={className} />
  )
}

export function getLifelineEventKey(event: LifelineEvent, index: number) {
  const content = getEventContent(event)

  if (typeof content === "string") return `${index}-${content}`

  return `${index}-${content.map((segment) => segment.value).join("")}`
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-hover-image.tsx
"use client"

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  type ReactNode,
} from "react"
import { clamp, snapToDevicePixel } from "./lifeline-utils"
import type { LifelineEventImage } from "./types"

const FOLLOW_EASE = 0.16
const TILT_FACTOR = 0.14
const TILT_MAX_DEG = 7
const TILT_EASE = 0.1
const CURSOR_OFFSET_X = 24
const CURSOR_OFFSET_Y = 96

interface LifelineHoverImageApi {
  show: (image: LifelineEventImage) => void
  hide: () => void
}

interface LifelineHoverImageProviderProps {
  children: ReactNode
  preload?: LifelineEventImage[]
}

const LifelineHoverImageContext = createContext<LifelineHoverImageApi | null>(
  null,
)

export function useLifelineHoverImage() {
  return useContext(LifelineHoverImageContext)
}

export function LifelineHoverImageProvider({
  children,
  preload,
}: LifelineHoverImageProviderProps) {
  const containerRef = useRef<HTMLDivElement>(null)
  const imageRef = useRef<HTMLImageElement>(null)
  const state = useRef({
    x: 0,
    y: 0,
    targetX: 0,
    targetY: 0,
    tilt: 0,
    visible: false,
    hoverCapable: false,
    frame: 0,
  })

  useEffect(() => {
    const s = state.current
    s.hoverCapable = window.matchMedia(
      "(hover: hover) and (pointer: fine)",
    ).matches

    const onMouseMove = (event: MouseEvent) => {
      s.targetX = event.clientX
      s.targetY = event.clientY
    }

    window.addEventListener("mousemove", onMouseMove, { passive: true })
    return () => {
      window.removeEventListener("mousemove", onMouseMove)
      cancelAnimationFrame(s.frame)
    }
  }, [])

  useEffect(() => {
    if (!preload?.length || !state.current.hoverCapable) return

    const warm = () => {
      preload.forEach(({ src }) => {
        if (!src) return
        const image = new window.Image()
        image.src = src
      })
    }

    if (typeof window.requestIdleCallback === "function") {
      const handle = window.requestIdleCallback(warm)
      return () => window.cancelIdleCallback(handle)
    }

    const timeout = window.setTimeout(warm, 2000)
    return () => window.clearTimeout(timeout)
  }, [preload])

  const stepRef = useRef<() => void>(() => {})

  const step = useCallback(() => {
    const s = state.current
    const container = containerRef.current
    if (!container) return

    const dx = s.targetX - s.x
    s.x += dx * FOLLOW_EASE
    s.y += (s.targetY - s.y) * FOLLOW_EASE

    const targetTilt = clamp(dx * TILT_FACTOR, -TILT_MAX_DEG, TILT_MAX_DEG)
    s.tilt += (targetTilt - s.tilt) * TILT_EASE

    if (
      Math.abs(s.targetX - s.x) < 0.1 &&
      Math.abs(s.targetY - s.y) < 0.1 &&
      Math.abs(s.tilt) < 0.05
    ) {
      s.x = s.targetX
      s.y = s.targetY
      s.tilt = 0
    }

    const rotate = s.tilt === 0 ? "" : ` rotate(${s.tilt}deg)`
    container.style.transform = `translate3d(${snapToDevicePixel(
      s.x + CURSOR_OFFSET_X,
    )}px, ${snapToDevicePixel(s.y - CURSOR_OFFSET_Y)}px, 0)${rotate}`

    if (s.visible) {
      s.frame = requestAnimationFrame(() => stepRef.current())
    }
  }, [])

  useEffect(() => {
    stepRef.current = step
  }, [step])

  const api = useMemo<LifelineHoverImageApi>(
    () => ({
      show(image) {
        const s = state.current
        const container = containerRef.current
        const img = imageRef.current
        if (!s.hoverCapable || !container || !img) return

        const targetSrc = new URL(image.src, window.location.origin).href

        if (img.src !== targetSrc) {
          img.style.visibility = "hidden"
          img.src = targetSrc
          img.alt = image.alt

          const reveal = () => {
            if (img.src === targetSrc) {
              img.style.visibility = "visible"
            }
          }

          if (img.complete) {
            reveal()
          } else {
            img.decode().then(reveal, reveal)
          }
        } else {
          img.style.visibility = "visible"
        }

        if (!s.visible) {
          s.x = s.targetX
          s.y = s.targetY
          s.tilt = 0
        }

        s.visible = true
        container.style.opacity = "1"
        img.style.transform = "scale(1)"

        cancelAnimationFrame(s.frame)
        s.frame = requestAnimationFrame(step)
      },
      hide() {
        const s = state.current
        const container = containerRef.current
        const img = imageRef.current
        if (!container || !img) return

        s.visible = false
        container.style.opacity = "0"
        img.style.transform = "scale(0.94)"
      },
    }),
    [step],
  )

  return (
    <LifelineHoverImageContext.Provider value={api}>
      {children}
      <div
        ref={containerRef}
        aria-hidden="true"
        className="pointer-events-none fixed left-0 top-0 z-60 opacity-0 transition-opacity duration-200 ease-out will-change-transform"
      >
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          ref={imageRef}
          alt=""
          className="w-70 scale-95 rounded-xl shadow-2xl ring-1 ring-black/10 transition-[transform,box-shadow] duration-200 ease-out dark:ring-black/40"
          decoding="async"
        />
      </div>
    </LifelineHoverImageContext.Provider>
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-inline-clip.tsx
"use client"

import { useCallback, useEffect, useRef, useState } from "react"
import { Play } from "lucide-react"
import { cn } from "@/lib/utils"
import {
  LifelineLightbox,
  type LifelineLightboxStart,
} from "./lifeline-lightbox"
import type { LifelineEventClip } from "./types"

export function LifelineInlineClip({
  clip,
  className,
}: {
  clip: LifelineEventClip
  className?: string
}) {
  const cardRef = useRef<HTMLDivElement>(null)
  const videoRef = useRef<HTMLVideoElement>(null)
  const [lightboxStart, setLightboxStart] =
    useState<LifelineLightboxStart | null>(null)

  useEffect(() => {
    const root = cardRef.current
    const video = videoRef.current
    if (!root || !video) return

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry?.isIntersecting) video.preload = "auto"
      },
      { rootMargin: "80px" },
    )
    observer.observe(root)
    return () => observer.disconnect()
  }, [])

  const measure = useCallback((): LifelineLightboxStart | null => {
    const el = cardRef.current
    if (!el) return null
    const rect = el.getBoundingClientRect()
    return {
      cx: rect.left + rect.width / 2,
      cy: rect.top + rect.height / 2,
      w: el.offsetWidth,
      h: el.offsetHeight,
    }
  }, [])

  return (
    <>
      <div
        ref={cardRef}
        data-lifeline-interactive=""
        className={cn(
          "relative mt-3 w-full max-w-[200px] cursor-zoom-in overflow-hidden rounded-xl shadow-lg ring-1 ring-black/10 dark:ring-black/40",
          lightboxStart && "invisible",
          className,
        )}
        onPointerDown={(event) => event.stopPropagation()}
        onClick={(event) => {
          event.stopPropagation()
          if (!lightboxStart) setLightboxStart(measure())
        }}
      >
        <video
          ref={videoRef}
          src={clip.src}
          playsInline
          preload="metadata"
          aria-label={clip.alt}
          className="block aspect-video w-full bg-black object-cover"
        />
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/25 text-white">
          <span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-black shadow">
            <Play className="h-3.5 w-3.5 translate-x-px" fill="currentColor" />
          </span>
        </div>
      </div>
      {lightboxStart && (
        <LifelineLightbox
          clip={clip}
          rotate={0}
          start={lightboxStart}
          getHome={measure}
          onClosed={() => setLightboxStart(null)}
        />
      )}
    </>
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-inline-still.tsx
"use client"

import { useCallback, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import {
  LifelineLightbox,
  type LifelineLightboxStart,
} from "./lifeline-lightbox"
import type { LifelineEventImage } from "./types"

export function LifelineInlineStill({
  image,
  className,
}: {
  image: LifelineEventImage
  className?: string
}) {
  const cardRef = useRef<HTMLDivElement>(null)
  const [lightboxStart, setLightboxStart] =
    useState<LifelineLightboxStart | null>(null)

  const measure = useCallback((): LifelineLightboxStart | null => {
    const el = cardRef.current
    if (!el) return null
    const rect = el.getBoundingClientRect()
    return {
      cx: rect.left + rect.width / 2,
      cy: rect.top + rect.height / 2,
      w: el.offsetWidth,
      h: el.offsetHeight,
    }
  }, [])

  return (
    <>
      <div
        ref={cardRef}
        data-lifeline-interactive=""
        className={cn(
          "relative mt-3 w-full max-w-[200px] cursor-zoom-in overflow-hidden rounded-xl shadow-lg ring-1 ring-black/10 dark:ring-black/40",
          lightboxStart && "invisible",
          className,
        )}
        onPointerDown={(event) => event.stopPropagation()}
        onClick={(event) => {
          event.stopPropagation()
          if (!lightboxStart) setLightboxStart(measure())
        }}
      >
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={image.src}
          alt={image.alt}
          className="block aspect-video w-full object-cover"
        />
      </div>
      {lightboxStart && (
        <LifelineLightbox
          photo={image}
          rotate={0}
          start={lightboxStart}
          getHome={measure}
          onClosed={() => setLightboxStart(null)}
        />
      )}
    </>
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-labels.tsx
export const LIFELINE_LABEL_COLUMN_WIDTH = 56
export const LIFELINE_LABEL_GAP = 16
export const LIFELINE_STICKY_SHIELD_WIDTH =
  LIFELINE_LABEL_COLUMN_WIDTH + LIFELINE_LABEL_GAP
export const LIFELINE_STICKY_LEFT = 12

export function LifelineStickyLabels() {
  return (
    <div
      className="relative"
      style={{ width: LIFELINE_LABEL_COLUMN_WIDTH }}
      aria-hidden="true"
    >
      <div className="flex flex-col items-start text-left">
        <p className="mb-5 h-4 whitespace-nowrap text-[15px] font-medium uppercase leading-4 tracking-[0.08em] text-muted-foreground transition-colors duration-300">
          Age
        </p>
        <p className="mb-6 h-5 whitespace-nowrap text-[15px] font-medium uppercase leading-5 tracking-[0.08em] text-muted-foreground transition-colors duration-300">
          Years
        </p>
      </div>
    </div>
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-legend.tsx
import type { LifelineLegendItem } from "./types"

const LEGEND_DOT_CLASS: Record<LifelineLegendItem["type"], string> = {
  globalEvent: "bg-pink-500",
  work: "bg-amber-500",
  college: "bg-violet-500",
  destination: "bg-teal-500",
}

const DEFAULT_ITEMS: LifelineLegendItem[] = [
  { type: "globalEvent", label: "Global events" },
  { type: "destination", label: "Destinations" },
  { type: "work", label: "Work" },
  { type: "college", label: "College" },
]

export function LifelineLegend({
  items = DEFAULT_ITEMS,
}: {
  items?: LifelineLegendItem[]
}) {
  return (
    <ul className="flex flex-wrap items-center gap-x-6 gap-y-2 text-[13px] text-muted-foreground">
      {items.map((item) => (
        <li key={item.type} className="flex items-center gap-2">
          <span
            className={`h-1.5 w-1.5 shrink-0 rounded-full ${LEGEND_DOT_CLASS[item.type]}`}
            aria-hidden="true"
          />
          <span>{item.label}</span>
        </li>
      ))}
    </ul>
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-lightbox.tsx
"use client"

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
} from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import type { LifelineEventClip, LifelineEventImage } from "./types"

const OPEN_MS = 520
const EASE = "cubic-bezier(0.32, 0.72, 0, 1)"
const FIT = 0.85

interface Target {
  left: number
  top: number
  width: number
  height: number
}

export interface LifelineLightboxStart {
  cx: number
  cy: number
  w: number
  h: number
}

function homeRadius(home: LifelineLightboxStart, targetWidth: number) {
  const scale = home.w / targetWidth
  if (scale <= 0) return "var(--radius-xl)"
  return `calc(var(--radius-xl) / ${scale})`
}

function LightboxMedia({
  photo,
  clip,
  showControls,
}: {
  photo?: LifelineEventImage
  clip?: LifelineEventClip
  showControls: boolean
}) {
  const videoRef = useRef<HTMLVideoElement>(null)

  useEffect(() => {
    const video = videoRef.current
    if (!clip || !video) return
    video.muted = false
    void video.play().catch(() => {})
  }, [clip])

  if (clip) {
    return (
      <video
        ref={videoRef}
        src={clip.src}
        playsInline
        controls={showControls}
        autoPlay
        aria-label={clip.alt}
        className="block h-full w-full bg-black object-cover"
        onClick={(event) => event.stopPropagation()}
        onPointerDown={(event) => event.stopPropagation()}
      />
    )
  }

  if (!photo) return null

  return (
    // eslint-disable-next-line @next/next/no-img-element
    <img
      src={photo.src}
      alt={photo.alt}
      className="block h-full w-full object-cover"
    />
  )
}

function computeTarget(start: LifelineLightboxStart): Target {
  const vw = window.innerWidth
  const vh = window.innerHeight
  const aspect = start.h / start.w
  const width = Math.min(vw * FIT, (vh * FIT) / aspect)
  const height = width * aspect
  return { left: (vw - width) / 2, top: (vh - height) / 2, width, height }
}

export function LifelineLightbox({
  photo,
  clip,
  rotate,
  start,
  getHome,
  onClosed,
}: {
  photo?: LifelineEventImage
  clip?: LifelineEventClip
  rotate: number
  start: LifelineLightboxStart
  getHome: () => LifelineLightboxStart | null
  onClosed: () => void
}) {
  const [target] = useState<Target>(() => computeTarget(start))
  const { left, top, width, height } = target

  const figureRef = useRef<HTMLElement>(null)

  const [reduceMotion] = useState(
    () =>
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches,
  )

  const toTransform = useCallback(
    (home: LifelineLightboxStart) =>
      `translate(${home.cx - (left + width / 2)}px, ${
        home.cy - (top + height / 2)
      }px) scale(${home.w / width}) rotate(${rotate}deg)`,
    [left, top, width, height, rotate],
  )

  const [entered, setEntered] = useState(reduceMotion)
  const [transform, setTransform] = useState(() =>
    reduceMotion ? "none" : toTransform(start),
  )
  const [borderRadius, setBorderRadius] = useState(() =>
    reduceMotion ? "var(--radius-xl)" : homeRadius(start, width),
  )
  const closing = useRef(false)

  useLayoutEffect(() => {
    if (reduceMotion) return
    let inner = 0
    const outer = requestAnimationFrame(() => {
      inner = requestAnimationFrame(() => {
        setEntered(true)
        setTransform("translate(0px, 0px) scale(1) rotate(0deg)")
        setBorderRadius("var(--radius-xl)")
      })
    })
    return () => {
      cancelAnimationFrame(outer)
      cancelAnimationFrame(inner)
    }
  }, [reduceMotion])

  const rootRef = useRef<HTMLDivElement>(null)
  useEffect(() => {
    const root = rootRef.current
    if (!root) return
    const block = (event: TouchEvent) => event.preventDefault()
    root.addEventListener("touchmove", block, { passive: false })
    return () => root.removeEventListener("touchmove", block)
  }, [])

  useEffect(() => {
    const swallow = (event: MouseEvent) => {
      event.stopPropagation()
      event.preventDefault()
    }
    window.addEventListener("click", swallow, { capture: true, once: true })
    const timeout = window.setTimeout(() => {
      window.removeEventListener("click", swallow, { capture: true })
    }, 500)
    return () => {
      window.clearTimeout(timeout)
      window.removeEventListener("click", swallow, { capture: true })
    }
  }, [])

  const dismiss = useCallback(() => {
    if (closing.current) return
    closing.current = true
    if (reduceMotion) {
      onClosed()
      return
    }
    const home = getHome() ?? start
    setEntered(false)
    setTransform(toTransform(home))
    setBorderRadius(homeRadius(home, width))
    window.setTimeout(onClosed, OPEN_MS + 120)
  }, [reduceMotion, onClosed, toTransform, getHome, start, width])

  useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") dismiss()
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [dismiss])

  return createPortal(
    <div
      ref={rootRef}
      className="fixed inset-0 z-[999] touch-none overscroll-contain"
      role="dialog"
      aria-modal="true"
      aria-label={photo?.alt ?? clip?.alt ?? "Media"}
      onPointerDown={(event) => event.stopPropagation()}
      onPointerMove={(event) => event.stopPropagation()}
      onPointerUp={(event) => event.stopPropagation()}
      onClick={(event) => event.stopPropagation()}
    >
      <div
        className={cn(
          "absolute inset-0 cursor-zoom-out bg-black/60 transition-opacity",
          entered ? "opacity-100" : "opacity-0",
        )}
        style={{ transitionDuration: `${OPEN_MS}ms` }}
        onClick={dismiss}
      />
      <figure
        ref={figureRef}
        className={cn(
          "absolute overflow-hidden shadow-2xl ring-1 ring-black/20",
          clip ? "cursor-default" : "cursor-zoom-out",
        )}
        style={{
          left,
          top,
          width,
          height,
          transform,
          borderRadius,
          transformOrigin: "center",
          transition: reduceMotion
            ? undefined
            : `transform ${OPEN_MS}ms ${EASE}, border-radius ${OPEN_MS}ms ${EASE}`,
          willChange: "transform, border-radius",
        }}
        onClick={clip ? undefined : dismiss}
        onTransitionEnd={(event) => {
          if (event.propertyName !== "transform") return
          if (closing.current) onClosed()
        }}
      >
        <LightboxMedia photo={photo} clip={clip} showControls={entered} />
      </figure>
    </div>,
    document.body,
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-marker.tsx
"use client"

import {
  forwardRef,
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type MouseEvent as ReactMouseEvent,
  type TransitionEvent as ReactTransitionEvent,
} from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { CompanyIcon } from "./company-icon"
import {
  getLifelineEventCategory,
  getLifelineEventImage,
  getLifelineEventKey,
  getLifelineEventVideo,
  LIFELINE_EVENT_CATEGORY_DOT_CLASS,
  LifelineEventText,
} from "./lifeline-event"
import { LifelineInlineClip } from "./lifeline-inline-clip"
import { LifelineInlineStill } from "./lifeline-inline-still"
import {
  aggregateLifelineGlobalEvents,
  LIFELINE_GLOBAL_EVENTS_VISIBLE,
  LifelineGlobalEvents,
} from "./lifeline-global-events"
import {
  clamp,
  LIFELINE_EXPAND_GESTURE,
  type LifelineExpandGestureDetail,
} from "./lifeline-utils"
import type { LifelineMarker } from "./types"

type ExpandPhase = "idle" | "opening" | "closing"

const SNAP_VELOCITY = 0.4

// Keep the rail itself fully opaque, then dissolve over a short band in the
// gap between the dotted timeline and the first title.
const RAIL_DISSOLVE =
  "linear-gradient(to bottom, rgb(255 255 255 / 0) 0, rgb(255 255 255 / 0) 14px, #fff 56px)"
const RAIL_DISSOLVE_MASK: CSSProperties = {
  maskImage: RAIL_DISSOLVE,
  WebkitMaskImage: RAIL_DISSOLVE,
}

const MOTION =
  "duration-[560ms] ease-[cubic-bezier(0.33,1,0.68,1)] motion-reduce:transition-none"

interface LifelineMarkerColumnProps {
  marker: LifelineMarker
  birthYear: number
  minWidth: number
  animateIntro?: boolean
  introDelay?: number
  introDuration?: number
}

function prefersReducedMotion() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}

export const LifelineMarkerColumn = forwardRef<
  HTMLDivElement,
  LifelineMarkerColumnProps
>(function LifelineMarkerColumn(
  {
    marker,
    birthYear,
    minWidth,
    animateIntro = false,
    introDelay = 0,
    introDuration = 420,
  },
  ref,
) {
  const age = marker.age ?? marker.year - birthYear
  const globalEvents = aggregateLifelineGlobalEvents(marker)

  // The timeline gives each year a fixed slot (navbar + footer pin it). Past
  // the visible count, the extra global events are held just below the fold;
  // grabbing up (or "More") scrolls the stack so they rise into view while
  // the year's content parks in the gap under the rail. The column never grows.
  const hiddenCount = globalEvents.length - LIFELINE_GLOBAL_EVENTS_VISIBLE
  const isTruncated = hiddenCount > 0

  const [expanded, setExpanded] = useState(false)
  const [phase, setPhase] = useState<ExpandPhase>("idle")
  const [windowHeight, setWindowHeight] = useState<number | null>(null)
  const [extra, setExtra] = useState(0)
  const [progress, setProgress] = useState(0)
  const [scrubbing, setScrubbing] = useState(false)

  const columnRef = useRef<HTMLDivElement>(null)
  const trackRef = useRef<HTMLDivElement>(null)
  const listRef = useRef<HTMLDivElement>(null)
  const extraRef = useRef(0)
  const progressRef = useRef(0)
  const originProgressRef = useRef(0)
  const accYRef = useRef(0)
  const scrubbingRef = useRef(false)

  const assignRef = useCallback(
    (node: HTMLDivElement | null) => {
      columnRef.current = node
      if (typeof ref === "function") ref(node)
      else if (ref) ref.current = node
    },
    [ref],
  )

  const setProgressValue = useCallback((next: number) => {
    const clamped = clamp(next, 0, 1)
    progressRef.current = clamped
    setProgress(clamped)
    setExpanded(clamped >= 0.5)
  }, [])

  const snapTo = useCallback(
    (target: 0 | 1) => {
      setScrubbing(false)
      scrubbingRef.current = false

      if (
        prefersReducedMotion() ||
        Math.abs(progressRef.current - target) < 0.001
      ) {
        setProgressValue(target)
        setPhase("idle")
        return
      }

      setPhase(target === 1 ? "opening" : "closing")
      setProgressValue(target)
    },
    [setProgressValue],
  )

  // Freeze the window to the collapsed stack's height so expanding shifts
  // content inside it instead of growing the column (and recentering the
  // rail off-screen). All events stay mounted so a vertical grab can scrub.
  useLayoutEffect(() => {
    if (!isTruncated) return
    const track = trackRef.current
    const list = listRef.current
    if (!track || !list) return

    const measure = () => {
      const items = [...list.children] as HTMLElement[]
      const lastVisible =
        items[Math.min(LIFELINE_GLOBAL_EVENTS_VISIBLE, items.length) - 1]
      if (!lastVisible) return

      const fold = Math.round(
        lastVisible.getBoundingClientRect().bottom -
          track.getBoundingClientRect().top,
      )
      const nextExtra = Math.max(0, track.offsetHeight - fold)
      extraRef.current = nextExtra
      setWindowHeight((prev) => (prev === fold ? prev : fold))
      setExtra((prev) => (prev === nextExtra ? prev : nextExtra))
    }

    measure()

    let raf = 0
    const scheduleMeasure = () => {
      cancelAnimationFrame(raf)
      raf = requestAnimationFrame(measure)
    }

    const observer = new ResizeObserver(scheduleMeasure)
    observer.observe(track)
    observer.observe(list)
    window.addEventListener("resize", scheduleMeasure)
    return () => {
      cancelAnimationFrame(raf)
      observer.disconnect()
      window.removeEventListener("resize", scheduleMeasure)
    }
  }, [isTruncated, globalEvents.length])

  useEffect(() => {
    if (!isTruncated) return
    const node = columnRef.current
    if (!node) return

    const onGesture = (event: Event) => {
      const { phase: gesturePhase, deltaY, velocityY } = (
        event as CustomEvent<LifelineExpandGestureDetail>
      ).detail

      if (gesturePhase === "start") {
        originProgressRef.current = progressRef.current
        accYRef.current = 0
        setScrubbing(true)
        scrubbingRef.current = true
        setPhase("idle")
        return
      }

      if (gesturePhase === "move") {
        const span = extraRef.current
        if (span <= 0) return
        accYRef.current += deltaY
        setProgressValue(originProgressRef.current - accYRef.current / span)
        return
      }

      const current = progressRef.current
      let target: 0 | 1
      if (velocityY < -SNAP_VELOCITY) target = 1
      else if (velocityY > SNAP_VELOCITY) target = 0
      else target = current > 0.5 ? 1 : 0
      snapTo(target)
    }

    node.addEventListener(LIFELINE_EXPAND_GESTURE, onGesture)
    return () => node.removeEventListener(LIFELINE_EXPAND_GESTURE, onGesture)
  }, [isTruncated, setProgressValue, snapTo])

  // Safety net for reduced-motion (no transitionend) or a dropped event.
  useEffect(() => {
    if (phase === "idle") return
    const id = window.setTimeout(() => setPhase("idle"), 720)
    return () => window.clearTimeout(id)
  }, [phase])

  function toggle(event: ReactMouseEvent) {
    event.stopPropagation()
    if (scrubbing || windowHeight == null || extra <= 0) return
    snapTo(progress > 0.5 ? 0 : 1)
  }

  function handleTrackTransitionEnd(
    event: ReactTransitionEvent<HTMLDivElement>,
  ) {
    if (event.target !== event.currentTarget || event.propertyName !== "transform") {
      return
    }
    setPhase("idle")
  }

  const isOpen = progress > 0.02
  const trackStyle: CSSProperties = {
    transform: `translateY(${-progress * extra}px)`,
  }

  const innerContent = (
    <>
      <div
        className={cn(
          "flex w-full flex-col items-start pt-6",
          globalEvents.length > 0 &&
            "min-h-[var(--lifeline-global-events-top)] pb-6",
        )}
      >
        {marker.badges && marker.badges.length > 0 && (
          <div className="mb-3 flex items-center justify-start gap-2">
            {marker.badges.map((badge) => (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                key={badge.src}
                src={badge.src}
                alt={badge.alt}
                className="h-6 w-6 object-contain opacity-80 transition-opacity duration-300 group-hover:opacity-100"
              />
            ))}
          </div>
        )}

        {marker.companies && marker.companies.length > 0 && (
          <div className="mb-2 flex items-center justify-start gap-1.5">
            {marker.companies.map((company) => (
              <CompanyIcon
                key={company.id}
                id={company.id}
                label={company.name}
                className="opacity-70 transition-opacity duration-300 group-hover:opacity-100"
              />
            ))}
          </div>
        )}

        <div className="min-h-[3.25rem] space-y-4">
          {marker.events.map((event, index) => {
            const image = getLifelineEventImage(event)
            const clip = getLifelineEventVideo(event)
            const category = getLifelineEventCategory(event)

            return (
              <div key={getLifelineEventKey(event, index)}>
                <p className="flex max-w-[18rem] items-start gap-2 text-left text-[14px] leading-[1.55] tracking-[-0.01em]">
                  {category && (
                    <span
                      className={cn("mt-[7px] size-1.5 shrink-0 rounded-full",
                        LIFELINE_EVENT_CATEGORY_DOT_CLASS[category],
                      )}
                      aria-hidden="true"
                    />
                  )}
                  <LifelineEventText event={event} />
                </p>
                {image && <LifelineInlineStill image={image} />}
                {clip && <LifelineInlineClip clip={clip} />}
              </div>
            )
          })}
        </div>
      </div>

      {globalEvents.length > 0 && (
        <div className="w-full">
          <LifelineGlobalEvents ref={listRef} events={globalEvents} />
        </div>
      )}
    </>
  )

  return (
    <div
      ref={assignRef}
      className="group relative shrink-0 pr-8 transition-opacity duration-300 ease-out will-change-opacity"
      style={{ width: minWidth }}
      aria-label={marker.label ?? `${marker.year}`}
      {...(isTruncated ? { "data-lifeline-expand": "" } : {})}
    >
      <div
        className={cn("relative", animateIntro && "lifeline-marker-intro")}
        style={{
          animationDelay: animateIntro ? `${introDelay}ms` : undefined,
          ...(animateIntro
            ? ({
                "--lifeline-marker-fade-ms": `${introDuration}ms`,
              } as CSSProperties)
            : {}),
        }}
      >
        <span
          className="absolute left-0 top-[var(--lifeline-rail)] z-10 h-[10px] w-px -translate-y-1/2 bg-muted-foreground transition-colors duration-300 group-hover:bg-muted-foreground"
          aria-hidden="true"
        />

        {/* Hairline over the pocket so the rail stays readable while content
            dissolves in the gap below it. */}
        {isTruncated && (
          <div
            aria-hidden="true"
            className={cn("pointer-events-none absolute left-0 right-0 top-[var(--lifeline-rail)] z-30 border-t border-dashed border-border transition-opacity duration-300",
              isOpen ? "opacity-100" : "opacity-0",
            )}
          />
        )}

        <div className="flex w-full flex-col items-start text-left">
          <p className="mb-5 h-4 text-[15px] font-medium leading-4 tabular-nums text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
            {age}
          </p>

          <p className="mb-6 h-5 whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
            {marker.label ?? marker.year}
          </p>

          <div className="relative w-full pb-10 text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
            {isTruncated ? (
              <div
                className="relative overflow-hidden"
                style={{
                  height: windowHeight ?? undefined,
                  ...(isOpen ? RAIL_DISSOLVE_MASK : null),
                }}
              >
                <div
                  ref={trackRef}
                  onTransitionEnd={handleTrackTransitionEnd}
                  className={cn(
                    "will-change-transform",
                    !scrubbing && "transition-transform",
                    !scrubbing && MOTION,
                  )}
                  style={trackStyle}
                >
                  {innerContent}
                </div>

                {/* Soft lower edge only while the stack is in motion. */}
                <div
                  aria-hidden="true"
                  className={cn("pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8 bg-gradient-to-t from-background to-transparent transition-opacity duration-200",
                    scrubbing || phase !== "idle" ? "opacity-100" : "opacity-0",
                  )}
                />
              </div>
            ) : (
              <div>{innerContent}</div>
            )}

            {isTruncated && (
              <button
                type="button"
                data-lifeline-interactive=""
                onClick={toggle}
                disabled={scrubbing}
                aria-expanded={expanded}
                aria-label={
                  expanded
                    ? `Collapse ${marker.label ?? marker.year}`
                    : `Show ${hiddenCount} more for ${
                        marker.label ?? marker.year
                      }`
                }
                className="group/more relative z-20 mt-4 flex w-full justify-center disabled:opacity-60"
              >
                <span className="flex size-7 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground/40 text-muted-foreground transition-colors duration-300 group-hover/more:border-muted-foreground/70 group-hover/more:text-foreground">
                  <ChevronDown
                    className={cn("size-3.5 transition-transform duration-300 motion-reduce:transition-none",
                      progress < 0.5 && "rotate-180",
                    )}
                  />
                </span>
              </button>
            )}
          </div>
        </div>
      </div>
    </div>
  )
})
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-global-events.tsx
import { forwardRef } from "react"
import { CompanyIcon } from "./company-icon"
import type { LifelineGlobalEvent, LifelineMarker } from "./types"

/** Max global events a single year lists before the rest collapse behind "More". */
export const LIFELINE_GLOBAL_EVENTS_VISIBLE = 4

export function aggregateLifelineGlobalEvents(
  marker: LifelineMarker,
): LifelineGlobalEvent[] {
  const map = new Map<string, LifelineGlobalEvent>()

  marker.globalEvents?.forEach((item) => {
    if (!map.has(item.name)) map.set(item.name, item)
  })

  return [...map.values()]
}

function getInitials(name: string) {
  return name
    .split(" ")
    .filter(Boolean)
    .map((part) => part[0])
    .join("")
    .slice(0, 2)
}

function LifelineGlobalEventPlace({ item }: { item: LifelineGlobalEvent }) {
  const place = item.place?.trim()
  const date = item.date?.trim()
  const fallback = item.role?.trim()

  if (!place && !date && !fallback) return null

  const label = [place, date].filter(Boolean).join(" · ") || fallback

  return (
    <p
      className="text-[11px] leading-snug text-pretty text-muted-foreground/80"
      title={label}
    >
      {place && date ? (
        <>
          <span className="wrap-break-word">{place}</span>
          <span className="text-muted-foreground/40"> · </span>
          <span className="whitespace-nowrap tabular-nums">{date}</span>
        </>
      ) : (
        <span className="wrap-break-word">{place || date || fallback}</span>
      )}
    </p>
  )
}

interface LifelineGlobalEventsProps {
  events: LifelineGlobalEvent[]
}

export const LifelineGlobalEvents = forwardRef<
  HTMLDivElement,
  LifelineGlobalEventsProps
>(function LifelineGlobalEvents({ events }, ref) {
  if (events.length === 0) return null

  return (
    <div ref={ref} className="flex w-full flex-col gap-3">
      {events.map((item) => (
        <div key={item.name} className="flex w-full items-start gap-2.5">
          <div className="flex h-7 w-3 shrink-0 items-center justify-center">
            <span
              className="size-1.5 rounded-full bg-pink-500"
              aria-hidden="true"
            />
          </div>
          {item.icon ? (
            <span className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-foreground">
              <CompanyIcon
                id={item.icon}
                label={item.name}
                className="h-4 w-5"
              />
            </span>
          ) : (
            <span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-foreground text-[10px] font-medium text-background transition-colors duration-300">
              {getInitials(item.name)}
            </span>
          )}
          <div className="min-w-0 text-left leading-snug">
            <p className="text-[13px] text-pretty text-muted-foreground transition-colors duration-300 group-hover:text-foreground">
              {item.name}
            </p>
            <LifelineGlobalEventPlace item={item} />
          </div>
        </div>
      ))}
    </div>
  )
})
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-vertical.tsx
"use client"

import { forwardRef, useEffect, useMemo, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
import { CompanyIcon } from "./company-icon"
import {
  getLifelineEventCategory,
  getLifelineEventImage,
  getLifelineEventKey,
  getLifelineEventVideo,
  LIFELINE_EVENT_CATEGORY_DOT_CLASS,
  LifelineEventText,
} from "./lifeline-event"
import { LifelineInlineClip } from "./lifeline-inline-clip"
import { LifelineInlineStill } from "./lifeline-inline-still"
import {
  aggregateLifelineGlobalEvents,
  LifelineGlobalEvents,
} from "./lifeline-global-events"
import type { LifelineEvent, LifelineMarker, LifelineProps } from "./types"
import { getMarkerHeight, hasMarkerContent } from "./lifeline-utils"
import { useLifelineIntro } from "./use-lifeline-intro"
import { useLifelineVerticalScroll } from "./use-lifeline-vertical-scroll"

const GRID_CLASS = "grid grid-cols-[2.5rem_1rem_1fr] gap-x-3"
const RAIL_LEFT = "calc(2.5rem + 0.75rem + 0.5rem)"

const MAX_ARMED_ENTRIES = 80

function RailTick() {
  return (
    <span
      aria-hidden="true"
      className="block h-px w-[10px] bg-muted-foreground transition-colors duration-300"
    />
  )
}

function LifelineVerticalEvent({ event }: { event: LifelineEvent }) {
  const image = getLifelineEventImage(event)
  const clip = getLifelineEventVideo(event)
  const category = getLifelineEventCategory(event)

  return (
    <div>
      <p className="flex max-w-[18rem] items-start gap-2 text-left text-[14px] leading-[1.55] tracking-[-0.01em]">
        {category && (
          <span
            className={cn("mt-[7px] size-1.5 shrink-0 rounded-full",
              LIFELINE_EVENT_CATEGORY_DOT_CLASS[category],
            )}
            aria-hidden="true"
          />
        )}
        <LifelineEventText event={event} />
      </p>
      {image && <LifelineInlineStill image={image} />}
      {clip && <LifelineInlineClip clip={clip} />}
    </div>
  )
}

const LifelineVerticalEntry = forwardRef<
  HTMLLIElement,
  {
    marker: LifelineMarker
    birthYear: number
    animateIntro?: boolean
    introDelay?: number
    introDuration?: number
    revealPending?: boolean
  }
>(function LifelineVerticalEntry(
  {
    marker,
    birthYear,
    animateIntro = false,
    introDelay = 0,
    introDuration = 420,
    revealPending = false,
  },
  ref,
) {
  const age = marker.age ?? marker.year - birthYear
  const globalEvents = aggregateLifelineGlobalEvents(marker)
  const hasContent = hasMarkerContent(marker)

  return (
    <li
      ref={ref}
      className={hasContent ? "pb-10" : "pb-3"}
      aria-label={marker.label ?? `${marker.year}`}
    >
      <div
        className={cn(
          animateIntro && "lifeline-marker-intro",
          revealPending && "opacity-0",
        )}
        style={{
          animationDelay: animateIntro ? `${introDelay}ms` : undefined,
          ...(animateIntro
            ? ({
                "--lifeline-marker-fade-ms": `${introDuration}ms`,
              } as CSSProperties)
            : {}),
        }}
      >
        <div className={`${GRID_CLASS} items-center`}>
          <p className="text-right text-[15px] font-medium leading-5 tabular-nums text-muted-foreground transition-colors duration-300">
            {age}
          </p>

          <div className="flex items-center justify-center">
            <RailTick />
          </div>

          <p className="whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-muted-foreground transition-colors duration-300">
            {marker.label ?? marker.year}
          </p>
        </div>

        {hasContent && (
          <div className={`${GRID_CLASS} mt-6`}>
            <div aria-hidden="true" />
            <div aria-hidden="true" />
            <div className="min-w-0 text-muted-foreground transition-colors duration-300">
              {marker.badges && marker.badges.length > 0 && (
                <div className="mb-3 flex items-center justify-start gap-2">
                  {marker.badges.map((badge) => (
                    // eslint-disable-next-line @next/next/no-img-element
                    <img
                      key={badge.src}
                      src={badge.src}
                      alt={badge.alt}
                      className="h-6 w-6 object-contain opacity-80"
                    />
                  ))}
                </div>
              )}

              {marker.companies && marker.companies.length > 0 && (
                <div className="mb-2 flex items-center justify-start gap-1.5">
                  {marker.companies.map((company) => (
                    <CompanyIcon
                      key={company.id}
                      id={company.id}
                      label={company.name}
                      className="opacity-70"
                    />
                  ))}
                </div>
              )}

              {marker.events.length > 0 && (
                <div className="space-y-4">
                  {marker.events.map((event, index) => (
                    <LifelineVerticalEvent
                      key={getLifelineEventKey(event, index)}
                      event={event}
                    />
                  ))}
                </div>
              )}

              {globalEvents.length > 0 && (
                <div className="mt-6 border-t border-border/70 pt-5 transition-colors duration-300">
                  <LifelineGlobalEvents events={globalEvents} />
                </div>
              )}
            </div>
          </div>
        )}
      </div>
    </li>
  )
})

export function LifelineVertical({
  markers,
  birthYear,
  title = "Lifeline",
  mode = "auto",
}: LifelineProps) {
  const isEmbed = mode === "embed"
  const heights = useMemo(
    () =>
      markers.map((marker, index) =>
        getMarkerHeight(marker, markers[index + 1]?.year),
      ),
    [markers],
  )

  const intro = useLifelineIntro(heights)
  const isIntroAnimating = intro.shouldPlay && intro.isPlaying

  useEffect(() => {
    const sources: string[] = []
    for (const marker of markers) {
      for (const event of marker.events) {
        const image = getLifelineEventImage(event)
        if (image?.src) sources.push(image.src)
      }
    }
    if (sources.length === 0) return

    const warm = () => {
      sources.forEach((src) => {
        const image = new window.Image()
        image.src = src
      })
    }

    if (typeof window.requestIdleCallback === "function") {
      const handle = window.requestIdleCallback(warm)
      return () => window.cancelIdleCallback(handle)
    }
    const timeout = window.setTimeout(warm, 2000)
    return () => window.clearTimeout(timeout)
  }, [markers])

  const { sectionRef, setEntryRef, isLayoutReady } = useLifelineVerticalScroll(
    markers.length,
    {
      isEmbed,
      introLocked: isIntroAnimating,
      introAnimating: isIntroAnimating,
      introSkipped: !intro.shouldPlay || isEmbed,
      introRailMs: intro.railDuration,
      introGetTrackProgress: intro.getTrackProgressAtTime,
      onIntroScrollStart: intro.startIntroTimer,
      onIntroSettleComplete: intro.completeIntro,
    },
  )

  const showIntro = isIntroAnimating && isLayoutReady && !isEmbed
  const revealOnScroll = markers.length > MAX_ARMED_ENTRIES
  const animateEntries = showIntro && !revealOnScroll

  useEffect(() => {
    if (!showIntro || !revealOnScroll) return
    const section = sectionRef.current
    const ol = section?.querySelector("ol")
    if (!section || !ol) return

    const entries = Array.from(ol.children) as HTMLElement[]
    const targets = entries.map(
      (li) => li.firstElementChild as HTMLElement | null,
    )

    const onAnimationEnd = (event: AnimationEvent) => {
      if (event.animationName !== "lifeline-marker-in") return
      ;(event.target as HTMLElement).classList.remove("lifeline-marker-intro")
    }
    section.addEventListener("animationend", onAnimationEnd)

    let next = 0
    let frame = 0
    const tick = () => {
      const progress = parseFloat(
        section.style.getPropertyValue("--lifeline-intro-progress") || "0",
      )
      const tip = progress * ol.offsetHeight

      while (next < entries.length && entries[next].offsetTop <= tip) {
        const el = targets[next]
        if (el) {
          el.classList.remove("opacity-0")
          el.classList.add("lifeline-marker-intro")
        }
        next++
      }

      if (next < entries.length) {
        frame = requestAnimationFrame(tick)
      }
    }
    frame = requestAnimationFrame(tick)

    return () => {
      cancelAnimationFrame(frame)
      section.removeEventListener("animationend", onAnimationEnd)
      targets.forEach((el) => {
        el?.classList.remove("opacity-0", "lifeline-marker-intro")
      })
    }
  }, [showIntro, revealOnScroll, sectionRef])

  const introStyle = {
    "--lifeline-labels-ms": `${intro.labelsDuration}ms`,
    "--lifeline-rail-ms": `${intro.railDuration}ms`,
  } as CSSProperties

  return (
    <article
      ref={sectionRef}
      aria-label={title}
      className={cn("relative select-none px-6 pb-10 pt-4 [&_a]:cursor-pointer",
        !isLayoutReady && "invisible",
      )}
      style={showIntro ? introStyle : undefined}
    >
      <div className={cn(`${GRID_CLASS} mb-6 items-end`, showIntro && "lifeline-labels-intro")}>
        <p className="text-right text-[15px] font-medium uppercase leading-5 tracking-[0.08em] text-muted-foreground transition-colors duration-300">
          Age
        </p>
        <div aria-hidden="true" />
        <p className="text-[15px] font-medium uppercase leading-5 tracking-[0.08em] text-muted-foreground transition-colors duration-300">
          Years
        </p>
      </div>

      <div className="relative">
        <div
          aria-hidden="true"
          className="pointer-events-none absolute bottom-0 top-0 overflow-hidden -translate-x-1/2"
          style={{ left: RAIL_LEFT, width: 1 }}
        >
          <div
            className={cn("h-full w-px border-l border-dashed border-border transition-colors duration-300",
              showIntro && "lifeline-rail-intro-vertical",
            )}
          />
        </div>

        <ol className="relative">
          {markers.map((marker, index) => (
            <LifelineVerticalEntry
              key={marker.id}
              ref={(node) => setEntryRef(index, node)}
              marker={marker}
              birthYear={birthYear}
              animateIntro={animateEntries}
              revealPending={showIntro && revealOnScroll}
              introDelay={intro.getMarkerDelay(index)}
              introDuration={intro.getMarkerFadeDuration(index)}
            />
          ))}
        </ol>
      </div>
    </article>
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/use-lifeline-intro.ts
"use client"

import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
  getTransitionMarkerFadeDuration,
  LIFELINE_FAST_MARKER_FADE_MS,
  timeAtTrackProgress,
  trackProgressAtTime,
} from "./lifeline-intro-timing"

export const LIFELINE_LABELS_MS = 600
export const LIFELINE_RAIL_MS = 3200
export const LIFELINE_REFERENCE_TRACK = 9000
export const LIFELINE_RAIL_MAX_MS = 7200
export const LIFELINE_RAIL_SCALE_POWER = 0.45
export const LIFELINE_FADE_SCALE_MAX = 1.5

export function useLifelineIntro(markerWidths: number[]) {
  const [shouldPlay] = useState(
    () =>
      typeof window === "undefined" ||
      !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
  )
  const [isPlaying, setIsPlaying] = useState(true)
  const [isComplete, setIsComplete] = useState(false)
  const introTimeoutRef = useRef(0)

  const totalMarkersWidth = useMemo(
    () => markerWidths.reduce((sum, width) => sum + width, 0),
    [markerWidths],
  )

  const railDuration = useMemo(() => {
    if (totalMarkersWidth <= LIFELINE_REFERENCE_TRACK) return LIFELINE_RAIL_MS

    const scale = Math.pow(
      totalMarkersWidth / LIFELINE_REFERENCE_TRACK,
      LIFELINE_RAIL_SCALE_POWER,
    )
    return Math.min(LIFELINE_RAIL_MAX_MS, Math.round(LIFELINE_RAIL_MS * scale))
  }, [totalMarkersWidth])

  const fadeScale = Math.min(
    LIFELINE_FADE_SCALE_MAX,
    railDuration / LIFELINE_RAIL_MS,
  )

  const introDuration =
    railDuration + Math.round(LIFELINE_FAST_MARKER_FADE_MS * fadeScale)

  const getTrackProgressAtTime = useCallback(
    (elapsedMs: number) => {
      if (!shouldPlay || totalMarkersWidth <= 0) {
        return Math.min(elapsedMs / railDuration, 1)
      }

      return trackProgressAtTime(elapsedMs, markerWidths, railDuration)
    },
    [markerWidths, railDuration, shouldPlay, totalMarkersWidth],
  )

  const getMarkerDelay = useCallback(
    (index: number) => {
      if (!shouldPlay || totalMarkersWidth <= 0) return 0

      const offset = markerWidths
        .slice(0, index)
        .reduce((sum, width) => sum + width, 0)

      return timeAtTrackProgress(
        offset / totalMarkersWidth,
        markerWidths,
        railDuration,
      )
    },
    [markerWidths, railDuration, shouldPlay, totalMarkersWidth],
  )

  const getMarkerFadeDuration = useCallback(
    (index: number) => {
      return Math.round(getTransitionMarkerFadeDuration(index) * fadeScale)
    },
    [fadeScale],
  )

  const completeIntro = useCallback(() => {
    setIsComplete(true)
  }, [])

  const startIntroTimer = useCallback(() => {
    window.clearTimeout(introTimeoutRef.current)
    setIsPlaying(true)
    setIsComplete(false)

    introTimeoutRef.current = window.setTimeout(() => {
      setIsPlaying(false)
    }, introDuration)
  }, [introDuration])

  useEffect(() => {
    return () => window.clearTimeout(introTimeoutRef.current)
  }, [])

  return {
    shouldPlay,
    isPlaying,
    isComplete,
    labelsDuration: LIFELINE_LABELS_MS,
    railDuration,
    getTrackProgressAtTime,
    getMarkerDelay,
    getMarkerFadeDuration,
    startIntroTimer,
    completeIntro,
  }
}
registry/new-york/templates/portfolio-v3/components/lifeline/use-lifeline-scroll.ts
"use client"

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import {
  LIFELINE_STICKY_LEFT,
  LIFELINE_STICKY_SHIELD_WIDTH,
} from "./lifeline-labels"
import {
  clamp,
  emitLifelineExpandGesture,
  findLifelineExpandHost,
  snapToDevicePixel,
} from "./lifeline-utils"
import type { LifelineMode } from "./types"

const FADE_ZONE = 200
const FADE_ZONE_COARSE = 72
const LEFT_EXIT_FADE_ZONE = 400
const LEFT_EXIT_FADE_ZONE_COARSE = 160
const WHEEL_SPEED = 1.4
const WHEEL_VELOCITY_FRAME_MS = 16.67
const WHEEL_MOMENTUM_BLEND = 0.65
const DRAG_SPEED = 1
const TOUCH_DRAG_SPEED = 1.15
const GESTURE_LOCK_PX = 8
const NAV_HORIZONTAL_PADDING = 24
const MOMENTUM_FRICTION = 0.94
const MOMENTUM_MIN_VELOCITY = 0.025
const MOMENTUM_MIN_START = 0.08

const LIFELINE_DEFAULT_START_INSET = 24

const PAGE_MODE_VIEWPORT_COVERAGE = 0.5
const PAGE_MODE_SCROLL_SLOP = 4
const MODE_RESOLVE_STALE_MS = 250

const WHEEL_GESTURE_QUIET_MS = 120
const EMBED_BOUNDARY_QUIET_MS = 260
const EMBED_BOUNDARY_MAX_HOLD_MS = 900

const EMBED_INTRO_ARM_MARGIN = "0px 0px 200px 0px"

function normalizeWheelDelta(event: WheelEvent) {
  let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
    ? event.deltaX
    : event.deltaY

  if (event.deltaMode === 1) delta *= 16
  if (event.deltaMode === 2) delta *= window.innerHeight

  return delta
}

function isInteractiveTarget(target: EventTarget | null) {
  return (
    target instanceof Element &&
    Boolean(target.closest("a, button, [data-lifeline-interactive]"))
  )
}

function isEditableTarget(target: EventTarget | null) {
  return (
    target instanceof Element &&
    Boolean(target.closest("input, textarea, select, [contenteditable]"))
  )
}

function getViewportCoverage(element: HTMLElement) {
  const rect = element.getBoundingClientRect()
  const visibleX =
    Math.min(rect.right, window.innerWidth) - Math.max(rect.left, 0)
  const visibleY =
    Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0)
  if (visibleX <= 0 || visibleY <= 0) return 0

  return (visibleX * visibleY) / (window.innerWidth * window.innerHeight)
}

function hasReleasableScroll(section: HTMLElement) {
  let node = section.parentElement

  while (node) {
    const { overflowY } = window.getComputedStyle(node)

    if (overflowY === "hidden" || overflowY === "clip") return false

    if (
      (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") &&
      node.scrollHeight > node.clientHeight + PAGE_MODE_SCROLL_SLOP
    ) {
      return true
    }

    node = node.parentElement
  }

  const doc = document.scrollingElement
  return Boolean(doc && doc.scrollHeight > doc.clientHeight + PAGE_MODE_SCROLL_SLOP)
}

interface LifelineScrollOptions {
  mode?: LifelineMode
  isCoarsePointer?: boolean
  introLocked?: boolean
  introAnimating?: boolean
  introSkipped?: boolean
  introRailMs?: number
  introGetTrackProgress?: (elapsedMs: number) => number
  onIntroSettleComplete?: () => void
  onIntroScrollStart?: () => void
}

export function useLifelineScroll(
  markerCount: number,
  options: LifelineScrollOptions = {},
) {
  const sectionRef = useRef<HTMLDivElement>(null)
  const trackRef = useRef<HTMLDivElement>(null)
  const labelsRef = useRef<HTMLDivElement>(null)
  const markerRefs = useRef<(HTMLDivElement | null)[]>([])
  const maxTranslate = useRef(0)
  const startInset = useRef(LIFELINE_DEFAULT_START_INSET)
  const endInset = useRef(0)
  const translatePx = useRef(0)
  const initialized = useRef(false)
  const dragging = useRef(false)
  const gestureAxis = useRef<"x" | "y" | null>(null)
  const gestureStart = useRef({ x: 0, y: 0 })
  const dragOrigin = useRef({ x: 0, translate: 0 })
  const dragVelocity = useRef(0)
  const lastPointerSample = useRef({ x: 0, y: 0, t: 0 })
  const expanding = useRef(false)
  const expandHost = useRef<Element | null>(null)
  const expandVelocityY = useRef(0)
  const lastPointerY = useRef(0)
  const activePointerId = useRef<number | null>(null)
  const isCoarsePointerRef = useRef(options.isCoarsePointer ?? false)
  const momentumId = useRef(0)
  const settleId = useRef(0)
  const introLockedRef = useRef(options.introLocked ?? false)
  const introAnimatingRef = useRef(options.introAnimating ?? false)
  const introSkippedRef = useRef(options.introSkipped ?? false)
  const onIntroSettleCompleteRef = useRef(options.onIntroSettleComplete)
  const onIntroScrollStartRef = useRef(options.onIntroScrollStart)
  const settlingRef = useRef(false)
  const introWasAnimatingRef = useRef(false)
  const introScrollId = useRef(0)
  const introScrollStart = useRef(0)
  const introStartedRef = useRef(false)
  const introGetTrackProgressRef = useRef(options.introGetTrackProgress)
  const applyTranslateRef = useRef<(value: number) => void>(() => {})
  const scheduleMeasureRef = useRef<() => void>(() => {})
  const modeRef = useRef<LifelineMode>(options.mode ?? "auto")
  const isEmbedRef = useRef(false)
  const modeResolvedAt = useRef(0)
  const prefersReducedMotionRef = useRef(false)
  const wheelStreamAt = useRef(0)
  const wheelGapMs = useRef(Number.POSITIVE_INFINITY)
  const gestureStartedHere = useRef(false)
  const gestureReleased = useRef(false)
  const boundaryHitAt = useRef(0)
  const [isLayoutReady, setIsLayoutReady] = useState(false)
  const [isEmbed, setIsEmbed] = useState(false)
  const [introArmed, setIntroArmed] = useState(false)

  modeRef.current = options.mode ?? "auto"
  isCoarsePointerRef.current = options.isCoarsePointer ?? false
  introLockedRef.current = options.introLocked ?? false
  introAnimatingRef.current = options.introAnimating ?? false
  introSkippedRef.current = options.introSkipped ?? false
  onIntroSettleCompleteRef.current = options.onIntroSettleComplete
  onIntroScrollStartRef.current = options.onIntroScrollStart
  introGetTrackProgressRef.current = options.introGetTrackProgress

  const setMarkerRef = useCallback(
    (index: number, node: HTMLDivElement | null) => {
      markerRefs.current[index] = node

      if (index === markerCount - 1 && node) {
        scheduleMeasureRef.current()
      }
    },
    [markerCount],
  )

  const getLabelStageLeft = useCallback((translate: number) => {
    const section = sectionRef.current
    if (!section) return { isSticky: false, labelLeft: 0 }

    const naturalLeft = startInset.current - translate
    const isSticky = naturalLeft <= LIFELINE_STICKY_LEFT

    return {
      isSticky,
      labelLeft: isSticky ? LIFELINE_STICKY_LEFT : naturalLeft,
    }
  }, [])

  const applyLabelSticky = useCallback(
    (translate: number) => {
      const labels = labelsRef.current
      const { isSticky, labelLeft } = getLabelStageLeft(translate)

      if (!labels) return { isSticky, labelLeft }

      if (isSticky) {
        const labelExtra =
          LIFELINE_STICKY_LEFT -
          snapToDevicePixel(startInset.current - translate)
        labels.style.transform = `translate3d(${labelExtra}px, 0, 0)`
        labels.classList.add("is-pinned")
      } else {
        labels.style.transform = ""
        labels.classList.remove("is-pinned")
      }

      return { isSticky, labelLeft }
    },
    [getLabelStageLeft],
  )

  const isScrollLocked = useCallback(() => {
    return (
      (introLockedRef.current && introStartedRef.current) ||
      settlingRef.current
    )
  }, [])

  const updateFades = useCallback(() => {
    if (settlingRef.current) return

    const section = sectionRef.current
    if (!section) return

    const stageRect = section.getBoundingClientRect()
    const isCoarse = isCoarsePointerRef.current
    const fadeZone = isCoarse ? FADE_ZONE_COARSE : FADE_ZONE
    const leftFadeZone = isCoarse
      ? LEFT_EXIT_FADE_ZONE_COARSE
      : LEFT_EXIT_FADE_ZONE

    markerRefs.current.forEach((marker) => {
      if (!marker) return

      const rect = marker.getBoundingClientRect()
      const markerLeft = rect.left - stageRect.left
      const center = markerLeft + rect.width / 2

      let opacity = 1

      const naturalLeft = markerLeft + translatePx.current
      const restLeft = Math.min(naturalLeft, leftFadeZone)
      if (markerLeft < restLeft) {
        opacity = markerLeft <= 0 ? 0 : markerLeft / restLeft
      }

      if (center > stageRect.width - fadeZone) {
        opacity = Math.min(opacity, (stageRect.width - center) / fadeZone)
      }

      if (isCoarse) {
        const readableLeft = LIFELINE_STICKY_SHIELD_WIDTH
        const readableRight = stageRect.width - 12
        const markerRight = rect.right - stageRect.left
        const visibleWidth =
          Math.min(markerRight, readableRight) - Math.max(markerLeft, readableLeft)
        const visibility = rect.width > 0 ? visibleWidth / rect.width : 0

        if (visibility >= 0.5) {
          opacity = 1
        }
      }

      marker.style.opacity = String(clamp(opacity, 0, 1))
    })
  }, [])

  const applyTranslate = useCallback(
    (value: number) => {
      const max = maxTranslate.current
      const next = clamp(value, 0, max)
      translatePx.current = next

      if (trackRef.current) {
        trackRef.current.style.transform = `translate3d(${snapToDevicePixel(
          startInset.current - next,
        )}px, 0, 0)`
      }

      applyLabelSticky(next)
      updateFades()
    },
    [applyLabelSticky, updateFades],
  )

  applyTranslateRef.current = applyTranslate

  const resolveMode = useCallback((force = false) => {
    const section = sectionRef.current
    if (!section) return isEmbedRef.current

    const mode = modeRef.current

    if (mode !== "auto") {
      const embed = mode === "embed"
      if (embed !== isEmbedRef.current) {
        isEmbedRef.current = embed
        setIsEmbed(embed)
      }
      return embed
    }

    const now = performance.now()
    if (!force && now - modeResolvedAt.current < MODE_RESOLVE_STALE_MS) {
      return isEmbedRef.current
    }
    modeResolvedAt.current = now

    const isPage =
      getViewportCoverage(section) >= PAGE_MODE_VIEWPORT_COVERAGE &&
      !hasReleasableScroll(section)

    if (isPage === !isEmbedRef.current) return isEmbedRef.current

    isEmbedRef.current = !isPage
    setIsEmbed(!isPage)
    return isEmbedRef.current
  }, [])

  const measureLayout = useCallback(() => {
    const track = trackRef.current
    const section = sectionRef.current
    if (!track || !section) return 0

    const embed = resolveMode(true)
    const stageRect = section.getBoundingClientRect()

    const navLogo = document.querySelector("[data-site-nav-logo]")
    const navInner = document.querySelector("[data-site-nav-inner]")

    const logoLeft = navLogo
      ? navLogo.getBoundingClientRect().left - stageRect.left
      : null
    const navRight = navInner
      ? navInner.getBoundingClientRect().right -
        stageRect.left -
        NAV_HORIZONTAL_PADDING
      : null

    const followChrome =
      !embed ||
      (logoLeft !== null &&
        navRight !== null &&
        logoLeft >= 0 &&
        navRight <= stageRect.width &&
        navRight > logoLeft)

    if (followChrome && logoLeft !== null) {
      startInset.current = logoLeft
    } else {
      startInset.current = LIFELINE_DEFAULT_START_INSET
    }

    if (followChrome && navRight !== null) {
      endInset.current = navRight
    } else {
      endInset.current = stageRect.width - NAV_HORIZONTAL_PADDING
    }

    const lastMarker = markerRefs.current[markerCount - 1]
    const lastMarkerRight = lastMarker
      ? LIFELINE_STICKY_SHIELD_WIDTH +
        lastMarker.offsetLeft +
        lastMarker.offsetWidth
      : track.scrollWidth

    const max = Math.max(
      0,
      startInset.current + lastMarkerRight - endInset.current,
    )
    maxTranslate.current = max

    return max
  }, [markerCount, resolveMode])

  useLayoutEffect(() => {
    markerRefs.current.length = markerCount
  }, [markerCount])

  useLayoutEffect(() => {
    const max = measureLayout()

    if (!initialized.current) {
      translatePx.current = introSkippedRef.current ? max : 0
      initialized.current = true
    }

    applyTranslate(translatePx.current)
    setIsLayoutReady(true)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useEffect(() => {
    if (!isEmbed) return

    const section = sectionRef.current
    if (!section || introArmed) return

    const observer = new IntersectionObserver(
      (entries) => {
        if (!entries.some((entry) => entry.isIntersecting)) return
        setIntroArmed(true)
        observer.disconnect()
      },
      { rootMargin: EMBED_INTRO_ARM_MARGIN },
    )

    observer.observe(section)
    return () => observer.disconnect()
  }, [isEmbed, introArmed])

  useEffect(() => {
    if (!isLayoutReady) return
    if (isEmbed && !introArmed) return
    if (options.introSkipped || !options.introAnimating) {
      cancelAnimationFrame(introScrollId.current)
      introScrollId.current = 0
      introStartedRef.current = false
      return
    }

    introWasAnimatingRef.current = true
    const railMs = options.introRailMs ?? 3200

    const step = (now: number) => {
      const max = maxTranslate.current

      if (max <= 0) {
        if (!introStartedRef.current) {
          introStartedRef.current = true
          introScrollStart.current = now
          onIntroScrollStartRef.current?.()
        }
        sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
        introScrollId.current = 0
        return
      }

      if (!introStartedRef.current) {
        introStartedRef.current = true
        introScrollStart.current = now
        onIntroScrollStartRef.current?.()
        sectionRef.current?.style.setProperty("--lifeline-intro-progress", "0")
        applyTranslateRef.current(0)
      }

      const elapsed = now - introScrollStart.current
      const progress = introGetTrackProgressRef.current
        ? clamp(introGetTrackProgressRef.current(elapsed), 0, 1)
        : clamp(elapsed / railMs, 0, 1)

      sectionRef.current?.style.setProperty(
        "--lifeline-intro-progress",
        String(progress),
      )
      applyTranslateRef.current(progress * max)

      if (progress < 1) {
        introScrollId.current = requestAnimationFrame(step)
        return
      }

      sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
      applyTranslateRef.current(max)
      introScrollId.current = 0
    }

    introScrollId.current = requestAnimationFrame(step)

    return () => {
      cancelAnimationFrame(introScrollId.current)
      introScrollId.current = 0
      introStartedRef.current = false
    }
  }, [
    introArmed,
    isEmbed,
    isLayoutReady,
    options.introAnimating,
    options.introRailMs,
    options.introSkipped,
  ])

  useEffect(() => {
    if (options.introSkipped) return

    if (options.introAnimating) return

    if (!introWasAnimatingRef.current) return
    introWasAnimatingRef.current = false

    sectionRef.current?.style.removeProperty("--lifeline-intro-progress")
    markerRefs.current.forEach((marker) => {
      if (marker) marker.style.opacity = ""
    })
    updateFades()
    onIntroSettleCompleteRef.current?.()
  }, [
    options.introAnimating,
    options.introSkipped,
    updateFades,
  ])

  useEffect(() => {
    return () => {
      initialized.current = false
    }
  }, [])

  useEffect(() => {
    const section = sectionRef.current
    if (!section) return

    let frameId = 0
    let resizeObserver: ResizeObserver | null = null

    const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
    const onMotionChange = () => {
      prefersReducedMotionRef.current = motionQuery.matches
    }
    prefersReducedMotionRef.current = motionQuery.matches
    motionQuery.addEventListener("change", onMotionChange)

    const stopMomentum = () => {
      cancelAnimationFrame(momentumId.current)
      momentumId.current = 0
    }

    const startMomentum = () => {
      if (Math.abs(dragVelocity.current) < MOMENTUM_MIN_START) return

      stopMomentum()
      let lastFrameTime = performance.now()

      const step = (now: number) => {
        const dt = Math.min(now - lastFrameTime, 32)
        lastFrameTime = now

        const velocity = dragVelocity.current
        if (Math.abs(velocity) < MOMENTUM_MIN_VELOCITY) {
          dragVelocity.current = 0
          momentumId.current = 0
          return
        }

        const max = maxTranslate.current
        const next = clamp(translatePx.current + velocity * dt, 0, max)

        if (next !== translatePx.current) {
          applyTranslate(next)
        }

        if (next <= 0 || next >= max) {
          if (isEmbedRef.current && boundaryHitAt.current === 0) {
            boundaryHitAt.current = performance.now()
          }
          dragVelocity.current = 0
          momentumId.current = 0
          return
        }

        dragVelocity.current =
          velocity * Math.pow(MOMENTUM_FRICTION, dt / 16.67)
        momentumId.current = requestAnimationFrame(step)
      }

      momentumId.current = requestAnimationFrame(step)
    }

    const measure = () => {
      const max = measureLayout()

      translatePx.current = clamp(translatePx.current, 0, max)

      if (!(introAnimatingRef.current && introStartedRef.current)) {
        applyTranslate(translatePx.current)
      }

      setIsLayoutReady(true)
    }

    const scheduleMeasure = () => {
      cancelAnimationFrame(frameId)
      frameId = requestAnimationFrame(measure)
    }

    scheduleMeasureRef.current = scheduleMeasure

    scheduleMeasure()
    frameId = requestAnimationFrame(() => {
      measure()
      requestAnimationFrame(measure)
    })

    resizeObserver = new ResizeObserver(scheduleMeasure)
    if (trackRef.current) resizeObserver.observe(trackRef.current)

    window.addEventListener("resize", scheduleMeasure)

    const scrub = (movement: number, target: number) => {
      applyTranslate(target)

      const impulse = (movement / WHEEL_VELOCITY_FRAME_MS) * 0.35
      dragVelocity.current =
        dragVelocity.current * (1 - WHEEL_MOMENTUM_BLEND) +
        impulse * WHEEL_MOMENTUM_BLEND

      if (prefersReducedMotionRef.current) return

      if (momentumId.current === 0) {
        startMomentum()
      }
    }

    const release = () => {
      gestureReleased.current = true
      stopMomentum()
      dragVelocity.current = 0
    }

    const onWheelStream = (event: WheelEvent) => {
      const now = performance.now()
      wheelGapMs.current = now - wheelStreamAt.current
      wheelStreamAt.current = now

      if (wheelGapMs.current <= WHEEL_GESTURE_QUIET_MS) return

      gestureStartedHere.current =
        event.target instanceof Node && section.contains(event.target)
      gestureReleased.current = false
      boundaryHitAt.current = 0
    }

    let expandWheelEndTimer = 0
    let lastExpandWheelAt = 0
    let expandWheelHost: Element | null = null

    const onWheel = (event: WheelEvent) => {
      if (isScrollLocked()) return

      if (expanding.current || dragging.current) return

      const horizontalIntent = Math.abs(event.deltaX) > Math.abs(event.deltaY)
      const wheelExpandHost = findLifelineExpandHost(event.target)

      if (wheelExpandHost && !horizontalIntent) {
        event.preventDefault()
        const now = performance.now()
        if (
          expandWheelHost !== wheelExpandHost ||
          now - lastExpandWheelAt > WHEEL_GESTURE_QUIET_MS
        ) {
          emitLifelineExpandGesture(wheelExpandHost, {
            phase: "start",
            deltaY: 0,
            velocityY: 0,
          })
          expandWheelHost = wheelExpandHost
        }
        lastExpandWheelAt = now
        emitLifelineExpandGesture(wheelExpandHost, {
          phase: "move",
          deltaY: event.deltaY,
          velocityY: 0,
        })
        window.clearTimeout(expandWheelEndTimer)
        expandWheelEndTimer = window.setTimeout(() => {
          emitLifelineExpandGesture(wheelExpandHost, {
            phase: "end",
            deltaY: 0,
            velocityY: 0,
          })
          expandWheelHost = null
        }, WHEEL_GESTURE_QUIET_MS)
        return
      }

      if (maxTranslate.current <= 0) {
        scheduleMeasure()
        return
      }

      const delta = normalizeWheelDelta(event)
      const movement = (horizontalIntent ? delta : -delta) * WHEEL_SPEED

      if (!resolveMode()) {
        event.preventDefault()
        scrub(movement, translatePx.current + movement)
        return
      }

      if (!horizontalIntent) {
        if (!gestureStartedHere.current) return
        if (gestureReleased.current) return
      }

      const target = clamp(
        translatePx.current + movement,
        0,
        maxTranslate.current,
      )

      if (target === translatePx.current && !horizontalIntent) {
        const now = performance.now()
        if (boundaryHitAt.current === 0) boundaryHitAt.current = now

        if (
          wheelGapMs.current < EMBED_BOUNDARY_QUIET_MS &&
          now - boundaryHitAt.current < EMBED_BOUNDARY_MAX_HOLD_MS
        ) {
          event.preventDefault()
          stopMomentum()
          dragVelocity.current = 0
          return
        }

        release()
        return
      }

      boundaryHitAt.current = 0
      event.preventDefault()
      scrub(movement, target)
    }

    const onSectionScroll = () => {
      if (section.scrollLeft !== 0) section.scrollLeft = 0
      if (section.scrollTop !== 0) section.scrollTop = 0
    }

    const capturePointer = (event: PointerEvent) => {
      if (section.setPointerCapture) {
        section.setPointerCapture(event.pointerId)
      }

      section.style.cursor = "grabbing"
      section.style.touchAction = "none"
    }

    const beginDrag = (event: PointerEvent) => {
      window.clearTimeout(expandWheelEndTimer)
      expandWheelHost = null
      stopMomentum()
      dragVelocity.current = 0
      dragging.current = true
      activePointerId.current = event.pointerId
      dragOrigin.current = { x: event.clientX, translate: translatePx.current }
      lastPointerSample.current = {
        x: event.clientX,
        y: event.clientY,
        t: performance.now(),
      }
      capturePointer(event)
    }

    const beginExpand = (event: PointerEvent) => {
      window.clearTimeout(expandWheelEndTimer)
      expandWheelHost = null
      expanding.current = true
      activePointerId.current = event.pointerId
      lastPointerY.current = event.clientY
      expandVelocityY.current = 0
      lastPointerSample.current = {
        x: event.clientX,
        y: event.clientY,
        t: performance.now(),
      }
      capturePointer(event)
      emitLifelineExpandGesture(expandHost.current, {
        phase: "start",
        deltaY: 0,
        velocityY: 0,
      })
    }

    const onPointerDown = (event: PointerEvent) => {
      if (isScrollLocked()) return
      if (isInteractiveTarget(event.target)) return
      if (activePointerId.current !== null) return

      expandHost.current = findLifelineExpandHost(event.target)
      if (maxTranslate.current <= 0 && !expandHost.current) return

      gestureAxis.current = null
      expanding.current = false
      gestureStart.current = { x: event.clientX, y: event.clientY }
      activePointerId.current = event.pointerId
    }

    const onPointerMove = (event: PointerEvent) => {
      if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {
        return
      }

      if (!dragging.current && !expanding.current) {
        if (activePointerId.current === null) return

        const deltaX = event.clientX - gestureStart.current.x
        const deltaY = event.clientY - gestureStart.current.y

        if (gestureAxis.current === null) {
          if (
            Math.abs(deltaX) < GESTURE_LOCK_PX &&
            Math.abs(deltaY) < GESTURE_LOCK_PX
          ) {
            return
          }

          gestureAxis.current =
            Math.abs(deltaX) >= Math.abs(deltaY) ? "x" : "y"

          if (gestureAxis.current === "x") {
            if (maxTranslate.current <= 0) {
              activePointerId.current = null
              return
            }
            beginDrag(event)
          } else if (expandHost.current) {
            beginExpand(event)
          } else {
            activePointerId.current = null
            return
          }
        }
      }

      if (expanding.current) {
        event.preventDefault()
        const now = performance.now()
        const sample = lastPointerSample.current
        const elapsed = now - sample.t
        const deltaY = event.clientY - lastPointerY.current

        if (elapsed > 0 && elapsed < 80) {
          expandVelocityY.current =
            ((event.clientY - sample.y) / elapsed) * 0.65 +
            expandVelocityY.current * 0.35
        }

        lastPointerY.current = event.clientY
        lastPointerSample.current = { x: event.clientX, y: event.clientY, t: now }
        emitLifelineExpandGesture(expandHost.current, {
          phase: "move",
          deltaY,
          velocityY: expandVelocityY.current,
        })
        return
      }

      if (!dragging.current) return

      if (event.pointerType === "touch") {
        event.preventDefault()
      }

      const now = performance.now()
      const sample = lastPointerSample.current
      const elapsed = now - sample.t
      const dragSpeed = event.pointerType === "touch" ? TOUCH_DRAG_SPEED : DRAG_SPEED

      if (elapsed > 0 && elapsed < 80) {
        const instantVelocity =
          (-(event.clientX - sample.x) / elapsed) * dragSpeed
        dragVelocity.current =
          instantVelocity * 0.65 + dragVelocity.current * 0.35
      }

      lastPointerSample.current = { x: event.clientX, y: event.clientY, t: now }

      const panDeltaX = event.clientX - dragOrigin.current.x
      applyTranslate(dragOrigin.current.translate - panDeltaX * dragSpeed)
    }

    const endDrag = (event: PointerEvent) => {
      if (activePointerId.current !== null && event.pointerId !== activePointerId.current) {
        return
      }

      const wasDragging = dragging.current
      const wasExpanding = expanding.current

      dragging.current = false
      expanding.current = false
      gestureAxis.current = null
      activePointerId.current = null

      if (section.hasPointerCapture(event.pointerId)) {
        section.releasePointerCapture(event.pointerId)
      }

      section.style.cursor = ""
      section.style.touchAction = ""

      if (wasExpanding) {
        emitLifelineExpandGesture(expandHost.current, {
          phase: "end",
          deltaY: 0,
          velocityY: expandVelocityY.current,
        })
        expandHost.current = null
        expandVelocityY.current = 0
        return
      }

      expandHost.current = null

      if (wasDragging) {
        startMomentum()
      }
    }

    const ownsKeyboard = () => {
      const active = document.activeElement
      if (active && section.contains(active)) return true

      return !resolveMode()
    }

    const onKeyDown = (event: KeyboardEvent) => {
      if (maxTranslate.current <= 0) return
      if (isScrollLocked()) return
      if (isEditableTarget(event.target)) return
      if (!ownsKeyboard()) return

      stopMomentum()
      dragVelocity.current = 0

      if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
        event.preventDefault()
        applyTranslate(translatePx.current - maxTranslate.current * 0.05)
      }

      if (event.key === "ArrowRight" || event.key === "ArrowDown") {
        event.preventDefault()
        applyTranslate(translatePx.current + maxTranslate.current * 0.05)
      }
    }

    window.addEventListener("wheel", onWheelStream, {
      passive: true,
      capture: true,
    })
    section.addEventListener("scroll", onSectionScroll, { passive: true })
    section.addEventListener("wheel", onWheel, { passive: false })
    section.addEventListener("pointerdown", onPointerDown)
    section.addEventListener("pointermove", onPointerMove, { passive: false })
    section.addEventListener("pointerup", endDrag)
    section.addEventListener("pointercancel", endDrag)
    window.addEventListener("keydown", onKeyDown)

    // Alias the ref (not its value): cleanup must cancel the latest settle frame.
    const settle = settleId
    return () => {
      cancelAnimationFrame(frameId)
      stopMomentum()
      cancelAnimationFrame(settle.current)
      settlingRef.current = false
      resizeObserver?.disconnect()
      motionQuery.removeEventListener("change", onMotionChange)
      window.removeEventListener("resize", scheduleMeasure)
      window.removeEventListener("wheel", onWheelStream, { capture: true })
      section.removeEventListener("scroll", onSectionScroll)
      section.removeEventListener("wheel", onWheel)
      section.removeEventListener("pointerdown", onPointerDown)
      section.removeEventListener("pointermove", onPointerMove)
      section.removeEventListener("pointerup", endDrag)
      section.removeEventListener("pointercancel", endDrag)
      window.removeEventListener("keydown", onKeyDown)
      dragging.current = false
      expanding.current = false
      expandHost.current = null
      gestureAxis.current = null
      activePointerId.current = null
      gestureStartedHere.current = false
      gestureReleased.current = false
      boundaryHitAt.current = 0
      wheelGapMs.current = Number.POSITIVE_INFINITY
      window.clearTimeout(expandWheelEndTimer)
      section.style.cursor = ""
      section.style.touchAction = ""
    }
  }, [applyTranslate, isScrollLocked, markerCount, measureLayout, resolveMode])

  return {
    sectionRef,
    trackRef,
    labelsRef,
    setMarkerRef,
    isLayoutReady,
    isEmbed,
    introArmed,
  }
}
registry/new-york/templates/portfolio-v3/components/lifeline/use-lifeline-vertical-scroll.ts
"use client"

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
import { clamp } from "./lifeline-utils"

function getScrollParent(element: HTMLElement | null): HTMLElement | null {
  let node = element?.parentElement ?? null

  while (node) {
    const { overflowY } = window.getComputedStyle(node)
    if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
      return node
    }
    node = node.parentElement
  }

  return (document.scrollingElement as HTMLElement | null) ?? null
}

interface LifelineVerticalScrollOptions {
  isEmbed?: boolean
  introLocked?: boolean
  introAnimating?: boolean
  introSkipped?: boolean
  introRailMs?: number
  introGetTrackProgress?: (elapsedMs: number) => number
  onIntroSettleComplete?: () => void
  onIntroScrollStart?: () => void
}

export function useLifelineVerticalScroll(
  markerCount: number,
  options: LifelineVerticalScrollOptions = {},
) {
  const sectionRef = useRef<HTMLElement>(null)
  const entryRefs = useRef<(HTMLLIElement | null)[]>([])
  const maxScrollRef = useRef(0)
  const scrollParentRef = useRef<HTMLElement | null>(null)
  const initialized = useRef(false)
  const introLockedRef = useRef(options.introLocked ?? false)
  const introAnimatingRef = useRef(options.introAnimating ?? false)
  const introSkippedRef = useRef(options.introSkipped ?? false)
  const isEmbedRef = useRef(options.isEmbed ?? false)
  const onIntroSettleCompleteRef = useRef(options.onIntroSettleComplete)
  const onIntroScrollStartRef = useRef(options.onIntroScrollStart)
  const introGetTrackProgressRef = useRef(options.introGetTrackProgress)
  const introStartedRef = useRef(false)
  const introScrollId = useRef(0)
  const introScrollStart = useRef(0)
  const introWasAnimatingRef = useRef(false)
  const scheduleMeasureRef = useRef<() => void>(() => {})
  const [isLayoutReady, setIsLayoutReady] = useState(false)

  introLockedRef.current = options.introLocked ?? false
  introAnimatingRef.current = options.introAnimating ?? false
  introSkippedRef.current = options.introSkipped ?? false
  isEmbedRef.current = options.isEmbed ?? false
  onIntroSettleCompleteRef.current = options.onIntroSettleComplete
  onIntroScrollStartRef.current = options.onIntroScrollStart
  introGetTrackProgressRef.current = options.introGetTrackProgress

  const setEntryRef = useCallback((index: number, node: HTMLLIElement | null) => {
    entryRefs.current[index] = node

    if (index === markerCount - 1 && node) {
      scheduleMeasureRef.current()
    }
  }, [markerCount])

  const applyScroll = useCallback((value: number) => {
    const scrollParent = scrollParentRef.current
    if (!scrollParent) return

    scrollParent.scrollTop = clamp(value, 0, maxScrollRef.current)
  }, [])

  const measureLayout = useCallback(() => {
    const section = sectionRef.current
    if (!section) return 0

    const scrollParent = getScrollParent(section)
    scrollParentRef.current = scrollParent

    if (!scrollParent) return 0

    const heights = entryRefs.current.map((entry) => entry?.offsetHeight ?? 0)
    if (heights.length < markerCount || heights.some((height) => height <= 0)) {
      return 0
    }

    const max = Math.max(0, scrollParent.scrollHeight - scrollParent.clientHeight)
    maxScrollRef.current = max

    return max
  }, [markerCount])

  useLayoutEffect(() => {
    entryRefs.current.length = markerCount
  }, [markerCount])

  useLayoutEffect(() => {
    const max = measureLayout()

    const scrollParent = scrollParentRef.current
    if (!scrollParent) return

    if (!initialized.current) {
      scrollParent.scrollTop =
        introSkippedRef.current && !isEmbedRef.current ? max : 0
      initialized.current = true
    }

    setIsLayoutReady(entryRefs.current.every((entry) => Boolean(entry)))
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useEffect(() => {
    if (!isLayoutReady) return
    if (options.introSkipped || !options.introAnimating) {
      cancelAnimationFrame(introScrollId.current)
      introScrollId.current = 0
      introStartedRef.current = false
      return
    }

    introWasAnimatingRef.current = true
    const railMs = options.introRailMs ?? 3200

    const step = (now: number) => {
      const max = maxScrollRef.current

      if (!introStartedRef.current) {
        introStartedRef.current = true
        introScrollStart.current = now
        onIntroScrollStartRef.current?.()
        sectionRef.current?.style.setProperty("--lifeline-intro-progress", "0")
        if (max > 0) applyScroll(0)
      }

      const elapsed = now - introScrollStart.current
      const progress = introGetTrackProgressRef.current
        ? clamp(introGetTrackProgressRef.current(elapsed), 0, 1)
        : clamp(elapsed / railMs, 0, 1)

      sectionRef.current?.style.setProperty(
        "--lifeline-intro-progress",
        String(progress),
      )

      if (max > 0) {
        applyScroll(progress * max)
      }

      if (progress < 1) {
        introScrollId.current = requestAnimationFrame(step)
        return
      }

      sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
      if (max > 0) applyScroll(max)
      introScrollId.current = 0
    }

    introScrollId.current = requestAnimationFrame(step)

    return () => {
      cancelAnimationFrame(introScrollId.current)
      introScrollId.current = 0
      introStartedRef.current = false
    }
  }, [
    applyScroll,
    isLayoutReady,
    options.introAnimating,
    options.introRailMs,
    options.introSkipped,
  ])

  useEffect(() => {
    if (options.introSkipped) return
    if (options.introAnimating) return
    if (!introWasAnimatingRef.current) return

    introWasAnimatingRef.current = false
    sectionRef.current?.style.removeProperty("--lifeline-intro-progress")
    onIntroSettleCompleteRef.current?.()
  }, [options.introAnimating, options.introSkipped])

  useEffect(() => {
    const section = sectionRef.current
    if (!section) return

    let frameId = 0
    let resizeObserver: ResizeObserver | null = null

    const measure = () => {
      measureLayout()

      const scrollParent = scrollParentRef.current
      if (!scrollParent) return

      if (!(introAnimatingRef.current && introStartedRef.current)) {
        scrollParent.scrollTop = clamp(
          scrollParent.scrollTop,
          0,
          maxScrollRef.current,
        )
      }

      setIsLayoutReady(
        entryRefs.current.length === markerCount &&
          entryRefs.current.every((entry) => Boolean(entry)),
      )
    }

    const scheduleMeasure = () => {
      cancelAnimationFrame(frameId)
      frameId = requestAnimationFrame(measure)
    }

    scheduleMeasureRef.current = scheduleMeasure

    scheduleMeasure()
    frameId = requestAnimationFrame(() => {
      measure()
      requestAnimationFrame(measure)
    })

    resizeObserver = new ResizeObserver(scheduleMeasure)
    resizeObserver.observe(section)

    window.addEventListener("resize", scheduleMeasure)

    const isScrollLocked = () =>
      introLockedRef.current && introStartedRef.current

    const preventScroll = (event: Event) => {
      if (!isScrollLocked()) return
      event.preventDefault()
    }

    const scrollParent = getScrollParent(section)
    scrollParentRef.current = scrollParent

    scrollParent?.addEventListener("wheel", preventScroll, { passive: false })
    scrollParent?.addEventListener("touchmove", preventScroll, { passive: false })

    return () => {
      cancelAnimationFrame(frameId)
      resizeObserver?.disconnect()
      window.removeEventListener("resize", scheduleMeasure)
      scrollParent?.removeEventListener("wheel", preventScroll)
      scrollParent?.removeEventListener("touchmove", preventScroll)
      initialized.current = false
    }
  }, [markerCount, measureLayout])

  return {
    sectionRef,
    setEntryRef,
    isLayoutReady,
  }
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-intro-timing.ts
import { clamp } from "./lifeline-utils"

export const LIFELINE_SLOW_YEARS = 5
export const LIFELINE_SLOW_TIME_RATIO = 0.38
export const LIFELINE_SLOW_MARKER_FADE_MS = 720
export const LIFELINE_FAST_MARKER_FADE_MS = 280
export const LIFELINE_EASE_POWER = 0

function smoothstep(value: number) {
  const t = clamp(value, 0, 1)
  return t * t * (3 - 2 * t)
}

export function getSlowTrackWidth(widths: number[]) {
  return widths
    .slice(0, LIFELINE_SLOW_YEARS)
    .reduce((sum, width) => sum + width, 0)
}

export function getSlowTrackPortion(widths: number[]) {
  const total = widths.reduce((sum, width) => sum + width, 0)
  if (total <= 0) return 0
  return getSlowTrackWidth(widths) / total
}

function getEasePower(widths: number[]) {
  if (LIFELINE_EASE_POWER > 0) return LIFELINE_EASE_POWER

  const slowPortion = getSlowTrackPortion(widths)
  const softenedPivot = smoothstep(LIFELINE_SLOW_TIME_RATIO)

  if (slowPortion <= 0 || softenedPivot <= 0 || softenedPivot >= 1) {
    return 2.6
  }

  return Math.log(slowPortion) / Math.log(softenedPivot)
}

function softenedTime(elapsedMs: number, railMs: number) {
  return smoothstep(elapsedMs / railMs)
}

export function trackProgressAtTime(
  elapsedMs: number,
  widths: number[],
  railMs: number,
) {
  if (railMs <= 0) return 0

  const t = softenedTime(elapsedMs, railMs)
  const power = getEasePower(widths)

  return clamp(Math.pow(t, power), 0, 1)
}

export function timeAtTrackProgress(
  progress: number,
  widths: number[],
  railMs: number,
) {
  const clamped = clamp(progress, 0, 1)
  if (clamped <= 0) return 0
  if (clamped >= 1) return railMs

  const power = getEasePower(widths)
  const softened = Math.pow(clamped, 1 / power)

  return invertSmoothstep(softened) * railMs
}

function invertSmoothstep(value: number) {
  const target = clamp(value, 0, 1)
  let lo = 0
  let hi = 1

  for (let i = 0; i < 20; i++) {
    const mid = (lo + hi) / 2
    if (smoothstep(mid) < target) lo = mid
    else hi = mid
  }

  return (lo + hi) / 2
}

export function isSlowIntroYear(index: number) {
  return index < LIFELINE_SLOW_YEARS
}

export function getTransitionMarkerFadeDuration(index: number) {
  if (index < LIFELINE_SLOW_YEARS) return LIFELINE_SLOW_MARKER_FADE_MS

  const rampYears = 3
  const rampIndex = index - LIFELINE_SLOW_YEARS
  if (rampIndex >= rampYears) return LIFELINE_FAST_MARKER_FADE_MS

  const t = (rampIndex + 1) / rampYears
  const blend = smoothstep(t)

  return Math.round(
    LIFELINE_SLOW_MARKER_FADE_MS +
      (LIFELINE_FAST_MARKER_FADE_MS - LIFELINE_SLOW_MARKER_FADE_MS) * blend,
  )
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-layout.ts
export const LIFELINE_MOBILE_BREAKPOINT = 768
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline-utils.ts
import type { LifelineMarker } from "./types"

export function clamp(value: number, min: number, max: number) {
  return Math.min(max, Math.max(min, value))
}

export function snapToDevicePixel(value: number) {
  const dpr = window.devicePixelRatio || 1
  return Math.round(value * dpr) / dpr
}

export const LIFELINE_EXPAND_SELECTOR = "[data-lifeline-expand]"
export const LIFELINE_EXPAND_GESTURE = "lifelineexpandgesture"

export interface LifelineExpandGestureDetail {
  phase: "start" | "move" | "end"
  deltaY: number
  velocityY: number
}

export function emitLifelineExpandGesture(
  host: Element | null,
  detail: LifelineExpandGestureDetail,
) {
  if (!host) return false
  host.dispatchEvent(
    new CustomEvent<LifelineExpandGestureDetail>(LIFELINE_EXPAND_GESTURE, {
      detail,
    }),
  )
  return true
}

export function dispatchLifelineExpandGesture(
  target: EventTarget | null,
  detail: LifelineExpandGestureDetail,
) {
  return emitLifelineExpandGesture(findLifelineExpandHost(target), detail)
}

export function findLifelineExpandHost(target: EventTarget | null) {
  return target instanceof Element
    ? target.closest(LIFELINE_EXPAND_SELECTOR)
    : null
}

export function hasMarkerContent(marker: LifelineMarker) {
  return (
    marker.events.length > 0 ||
    (marker.companies?.length ?? 0) > 0 ||
    (marker.globalEvents?.length ?? 0) > 0
  )
}

export function hasMarkerGlobalEvents(marker: LifelineMarker) {
  return (marker.globalEvents?.length ?? 0) > 0
}

function markerInlineMediaCount(marker: LifelineMarker) {
  return marker.events.filter(
    (event) =>
      typeof event === "object" &&
      !Array.isArray(event) &&
      Boolean(event.video || event.image),
  ).length
}

export function getMarkerHeight(marker: LifelineMarker, nextYear?: number) {
  const hasContent = hasMarkerContent(marker)
  const hasGlobalEvents = hasMarkerGlobalEvents(marker)

  if (!hasContent) return 48

  const globalEventsOnly =
    hasGlobalEvents &&
    marker.events.length === 0 &&
    (marker.companies?.length ?? 0) === 0

  let height = 96

  if (marker.companies?.length) height += 28
  height += marker.events.length * 44
  height += markerInlineMediaCount(marker) * 150

  if (globalEventsOnly) height += 88
  else if (hasGlobalEvents) height += 108

  if (!nextYear)
    return Math.min(560, Math.max(globalEventsOnly ? 148 : 188, height))

  const gap = Math.max(1, nextYear - marker.year)
  height += Math.min(32, gap * 3)

  return Math.min(560, Math.max(globalEventsOnly ? 148 : 188, height))
}

export function getMarkerWidth(marker: LifelineMarker, nextYear?: number) {
  const hasContent = hasMarkerContent(marker)
  const hasGlobalEvents = hasMarkerGlobalEvents(marker)
  const hasInlineMedia = markerInlineMediaCount(marker) > 0

  if (!nextYear) {
    if (hasInlineMedia) return 300
    return hasContent ? 360 : 80
  }
  if (!hasContent) return 80

  const globalEventsOnly =
    hasGlobalEvents &&
    marker.events.length === 0 &&
    (marker.companies?.length ?? 0) === 0

  if (globalEventsOnly) return 220

  const gap = Math.max(1, nextYear - marker.year)
  const width = Math.min(420, Math.max(290, gap * 36))
  return hasInlineMedia ? Math.max(width, 260) : width
}
registry/new-york/templates/portfolio-v3/components/lifeline/types.ts
import type { CompanyIconId } from "./company-icon"

export interface LifelineGlobalEvent {
  name: string
  /** Venue or city. Wraps onto the next line when it does not fit. */
  place?: string
  date?: string
  /** Freeform subtitle if `place` / `date` are not split. */
  role?: string
  icon?: CompanyIconId
}

export interface LifelineCompany {
  id: CompanyIconId
  name: string
}

export type LifelineEventSegment =
  | { type: "text"; value: string }
  | { type: "link"; value: string; href: string }

export interface LifelineEventImage {
  src: string
  alt: string
}

export interface LifelineEventClip {
  src: string
  alt: string
}

export type LifelineEventCategory = "work" | "college" | "destination"

export interface LifelineEventObject {
  text: string | LifelineEventSegment[]
  image?: LifelineEventImage
  video?: LifelineEventClip
  category?: LifelineEventCategory
}

export type LifelineEvent =
  | string
  | LifelineEventSegment[]
  | LifelineEventObject

export interface LifelineMarker {
  id: string
  year: number
  age?: number | string
  label?: string
  events: LifelineEvent[]
  badges?: { src: string; alt: string }[]
  companies?: LifelineCompany[]
  globalEvents?: LifelineGlobalEvent[]
}

export interface LifelineLegendItem {
  type: "globalEvent" | LifelineEventCategory
  label: string
}

export type LifelineMode = "auto" | "page" | "embed"

export interface LifelineProps {
  markers: LifelineMarker[]
  birthYear: number
  className?: string
  title?: string
  mode?: LifelineMode
}
registry/new-york/templates/portfolio-v3/components/lifeline/lifeline.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;
}

/* Fade the shield's trailing edge so horizontally scrolled markers dissolve
   behind the pinned labels rather than clipping against a visible box edge. */
.lifeline-labels-shield {
  -webkit-mask-image: linear-gradient(
    to right,
    #000 calc(100% - 18px),
    transparent
  );
  mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
}

.lifeline-typeset {
  font-family: var(
    --lifeline-font,
    var(--font-geist-sans),
    ui-sans-serif,
    system-ui,
    sans-serif
  );
}

@media (prefers-reduced-motion: reduce) {
  .lifeline-marker-intro,
  .lifeline-labels-intro {
    animation: none;
    opacity: 1;
  }

  .lifeline-rail-intro,
  .lifeline-rail-intro-vertical {
    transform: none;
  }
}
registry/new-york/templates/portfolio-v3/components/theme-provider.tsx
"use client"

import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ReactNode } from "react"

export function ThemeProvider({ children }: { children: ReactNode }) {
  return (
    <NextThemesProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      {children}
    </NextThemesProvider>
  )
}
registry/new-york/templates/portfolio-v3/components/theme-switcher.tsx
"use client"

import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { useSyncExternalStore } from "react"
import { cn } from "@/lib/utils"

const emptySubscribe = () => () => {}

function useIsClient() {
  return useSyncExternalStore(emptySubscribe, () => true, () => false)
}

export function ThemeSwitcher() {
  const { setTheme, resolvedTheme } = useTheme()
  const mounted = useIsClient()

  if (!mounted) {
    return <span className="inline-block h-4 w-4" aria-hidden="true" />
  }

  const isDark = resolvedTheme === "dark"

  return (
    <button
      type="button"
      onClick={() => setTheme(isDark ? "light" : "dark")}
      aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
      className={cn("inline-flex items-center p-0 text-muted-foreground transition-colors duration-300 hover:text-foreground",
      )}
    >
      {isDark ? (
        <Sun className="h-4 w-4" strokeWidth={1.75} />
      ) : (
        <Moon className="h-4 w-4" strokeWidth={1.75} />
      )}
    </button>
  )
}
registry/new-york/templates/portfolio-v3/components/ui/button.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
        outline:
          "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default:
          "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
        lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
        icon: "size-8",
        "icon-xs":
          "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
        "icon-lg": "size-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

function Button({
  className,
  variant = "default",
  size = "default",
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot.Root : "button"

  return (
    <Comp
      data-slot="button"
      data-variant={variant}
      data-size={size}
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }
registry/new-york/templates/portfolio-v3/components/ui/svgs/cursorDark.tsx
import type { SVGProps } from "react"

const CursorDark = (props: SVGProps<SVGSVGElement>) => (
  <svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09">
    <path
      fill="currentColor"
      d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
    />
  </svg>
)

export { CursorDark }
registry/new-york/templates/portfolio-v3/components/ui/svgs/cursorLight.tsx
import type { SVGProps } from "react"

const CursorLight = (props: SVGProps<SVGSVGElement>) => (
  <svg {...props} id="Ebene_1" version="1.1" viewBox="0 0 466.73 532.09">
    <path
      fill="currentColor"
      d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
    />
  </svg>
)

export { CursorLight }
registry/new-york/templates/portfolio-v3/components/ui/svgs/nvidiaIconDark.tsx
import type { SVGProps } from "react"

const NvidiaIconDark = (props: SVGProps<SVGSVGElement>) => (
  <svg {...props} xmlSpace="preserve" viewBox="35.188 31.512 351.46 258.785">
    <path
      fill="#fff"
      d="M384.195 282.109c0 3.771-2.769 6.302-6.047 6.302v-.023c-3.371.023-6.089-2.508-6.089-6.278 0-3.769 2.718-6.293 6.089-6.293 3.279-.001 6.047 2.523 6.047 6.292zm2.453 0c0-5.175-4.02-8.179-8.5-8.179-4.511 0-8.531 3.004-8.531 8.179 0 5.172 4.021 8.188 8.531 8.188 4.481 0 8.5-3.016 8.5-8.188m-9.91.692h.91l2.109 3.703h2.316l-2.336-3.859c1.207-.086 2.2-.661 2.2-2.286 0-2.019-1.392-2.668-3.75-2.668h-3.411v8.813h1.961v-3.703m.001-1.492v-2.122h1.364c.742 0 1.753.06 1.753.965 0 .985-.523 1.157-1.398 1.157h-1.719M329.406 237.027l10.598 28.993H318.48l10.926-28.993zm-11.35-11.289-24.423 61.88h17.246l3.863-10.934h28.903l3.656 10.934h18.722l-24.605-61.888-23.362.008zm-49.033 61.903h17.497v-61.922l-17.5-.004.003 61.926zm-121.467-61.926-14.598 49.078-13.984-49.074-18.879-.004 19.972 61.926h25.207l20.133-61.926h-17.851zm70.725 13.484h7.52c10.91 0 17.966 4.898 17.966 17.609 0 12.714-7.056 17.613-17.966 17.613h-7.52v-35.222zm-17.35-13.484v61.926h28.366c15.113 0 20.048-2.512 25.384-8.148 3.769-3.957 6.207-12.641 6.207-22.134 0-8.707-2.063-16.468-5.66-21.304-6.481-8.649-15.817-10.34-29.75-10.34h-24.547zm-165.743-.086v62.012h17.645v-47.086l13.672.004c4.527 0 7.754 1.128 9.934 3.457 2.765 2.945 3.894 7.699 3.894 16.395v27.23h17.098v-34.262c0-24.453-15.586-27.75-30.836-27.75H35.188zm137.583.086.007 61.926h17.489v-61.926h-17.496z"
    />
    <path
      fill="#77B900"
      d="M82.211 102.414s22.504-33.203 67.437-36.638V53.73c-49.769 3.997-92.867 46.149-92.867 46.149s24.41 70.565 92.867 77.026v-12.804c-50.237-6.32-67.437-61.687-67.437-61.687zm67.437 36.223v11.726c-37.968-6.769-48.507-46.237-48.507-46.237s18.23-20.195 48.507-23.47v12.867c-.023 0-.039-.007-.058-.007-15.891-1.907-28.305 12.938-28.305 12.938s6.958 24.991 28.363 32.183m0-107.125V53.73c1.461-.112 2.922-.207 4.391-.257 56.582-1.907 93.449 46.406 93.449 46.406s-42.343 51.488-86.457 51.488c-4.043 0-7.828-.375-11.383-1.005v13.739c3.04.386 6.192.613 9.481.613 41.051 0 70.738-20.965 99.484-45.778 4.766 3.817 24.278 13.103 28.289 17.168-27.332 22.883-91.031 41.329-127.144 41.329-3.481 0-6.824-.211-10.11-.528v19.306H305.68V31.512H149.648zm0 49.144V65.777c1.446-.101 2.903-.179 4.391-.226 40.688-1.278 67.382 34.965 67.382 34.965s-28.832 40.043-59.746 40.043c-4.449 0-8.438-.715-12.028-1.922V93.523c15.84 1.914 19.028 8.911 28.551 24.786l21.18-17.859s-15.461-20.277-41.524-20.277c-2.833-.001-5.544.198-8.206.483"
    />
  </svg>
)

export { NvidiaIconDark }
registry/new-york/templates/portfolio-v3/components/ui/svgs/nvidiaIconLight.tsx
import type { SVGProps } from "react"

const NvidiaIconLight = (props: SVGProps<SVGSVGElement>) => (
  <svg {...props} xmlSpace="preserve" viewBox="35.188 31.512 351.46 258.785">
    <path d="M384.195 282.109c0 3.771-2.769 6.302-6.047 6.302v-.023c-3.371.023-6.089-2.508-6.089-6.278 0-3.769 2.718-6.293 6.089-6.293 3.279-.001 6.047 2.523 6.047 6.292zm2.453 0c0-5.175-4.02-8.179-8.5-8.179-4.511 0-8.531 3.004-8.531 8.179 0 5.172 4.021 8.188 8.531 8.188 4.481 0 8.5-3.016 8.5-8.188m-9.91.692h.91l2.109 3.703h2.316l-2.336-3.859c1.207-.086 2.2-.661 2.2-2.286 0-2.019-1.392-2.668-3.75-2.668h-3.411v8.813h1.961v-3.703m.001-1.492v-2.122h1.364c.742 0 1.753.06 1.753.965 0 .985-.523 1.157-1.398 1.157h-1.719M329.406 237.027l10.598 28.993H318.48l10.926-28.993zm-11.35-11.289-24.423 61.88h17.246l3.863-10.934h28.903l3.656 10.934h18.722l-24.605-61.888-23.362.008zm-49.033 61.903h17.497v-61.922l-17.5-.004.003 61.926zm-121.467-61.926-14.598 49.078-13.984-49.074-18.879-.004 19.972 61.926h25.207l20.133-61.926h-17.851zm70.725 13.484h7.52c10.91 0 17.966 4.898 17.966 17.609 0 12.714-7.056 17.613-17.966 17.613h-7.52v-35.222zm-17.35-13.484v61.926h28.366c15.113 0 20.048-2.512 25.384-8.148 3.769-3.957 6.207-12.641 6.207-22.134 0-8.707-2.063-16.468-5.66-21.304-6.481-8.649-15.817-10.34-29.75-10.34h-24.547zm-165.743-.086v62.012h17.645v-47.086l13.672.004c4.527 0 7.754 1.128 9.934 3.457 2.765 2.945 3.894 7.699 3.894 16.395v27.23h17.098v-34.262c0-24.453-15.586-27.75-30.836-27.75H35.188zm137.583.086.007 61.926h17.489v-61.926h-17.496z" />
    <path
      fill="#77B900"
      d="M82.211 102.414s22.504-33.203 67.437-36.638V53.73c-49.769 3.997-92.867 46.149-92.867 46.149s24.41 70.565 92.867 77.026v-12.804c-50.237-6.32-67.437-61.687-67.437-61.687zm67.437 36.223v11.726c-37.968-6.769-48.507-46.237-48.507-46.237s18.23-20.195 48.507-23.47v12.867c-.023 0-.039-.007-.058-.007-15.891-1.907-28.305 12.938-28.305 12.938s6.958 24.991 28.363 32.183m0-107.125V53.73c1.461-.112 2.922-.207 4.391-.257 56.582-1.907 93.449 46.406 93.449 46.406s-42.343 51.488-86.457 51.488c-4.043 0-7.828-.375-11.383-1.005v13.739c3.04.386 6.192.613 9.481.613 41.051 0 70.738-20.965 99.484-45.778 4.766 3.817 24.278 13.103 28.289 17.168-27.332 22.883-91.031 41.329-127.144 41.329-3.481 0-6.824-.211-10.11-.528v19.306H305.68V31.512H149.648zm0 49.144V65.777c1.446-.101 2.903-.179 4.391-.226 40.688-1.278 67.382 34.965 67.382 34.965s-28.832 40.043-59.746 40.043c-4.449 0-8.438-.715-12.028-1.922V93.523c15.84 1.914 19.028 8.911 28.551 24.786l21.18-17.859s-15.461-20.277-41.524-20.277c-2.833-.001-5.544.198-8.206.483"
    />
  </svg>
)

export { NvidiaIconLight }
registry/new-york/templates/portfolio-v3/components/ui/svgs/vercel.tsx
import type { SVGProps } from "react"

const Vercel = (props: SVGProps<SVGSVGElement>) => (
  <svg {...props} viewBox="0 0 256 222" preserveAspectRatio="xMidYMid">
    <path d="m128 0 128 221.705H0z" />
  </svg>
)

export { Vercel }
registry/new-york/templates/portfolio-v3/components/ui/svgs/vercelDark.tsx
import type { SVGProps } from "react"

const VercelDark = (props: SVGProps<SVGSVGElement>) => (
  <svg {...props} viewBox="0 0 256 222" preserveAspectRatio="xMidYMid">
    <path fill="#fff" d="m128 0 128 221.705H0z" />
  </svg>
)

export { VercelDark }
registry/new-york/templates/portfolio-v3/lib/jon-doe.ts
export type JonEventSegment =
  | { type: "text"; value: string }
  | { type: "link"; value: string; href: string }

export type JonEventCategory = "work" | "college" | "destination"

export type JonEvent =
  | string
  | JonEventSegment[]
  | {
      text: string | JonEventSegment[]
      image?: string
      video?: string
      category?: JonEventCategory
    }

export type JonGlobalEvent = {
  name: string
  place?: string
  date?: string
  role?: string
  icon?: string
}

export type JonMilestone = {
  id: string
  events: JonEvent[]
  companies?: { id: string; name: string }[]
  globalEvents?: JonGlobalEvent[]
}

export const jonDoeMeta = {
  slug: "jon-doe",
  name: "Jon Doe",
  birthYear: 1992,
  endYear: 2026,
  description:
    "Fullstack developer — education, shipped projects, and the rooms where the work happens.",
  legend: [
    { type: "globalEvent" as const, label: "Global events" },
    { type: "destination" as const, label: "Destinations" },
    { type: "work" as const, label: "Work" },
    { type: "college" as const, label: "College" },
  ],
}

export const jonDoeMilestones: Record<number, JonMilestone> = {
  1992: {
    id: "born",
    events: ["I was born in Portland, Oregon."],
  },
  2010: {
    id: "university",
    events: [
      {
        text: [
          {
            type: "link",
            value: "University of Washington",
            href: "https://www.cs.washington.edu/",
          },
        ],
        category: "college",
      },
    ],
  },
  2014: {
    id: "japan",
    events: [
      {
        text: "Japan",
        image: "destinations/japan.jpg",
        category: "destination",
      },
    ],
  },
  2015: {
    id: "neuralarc",
    events: [
      {
        text: [
          { type: "text", value: "Joined " },
          {
            type: "link",
            value: "NeuralArc",
            href: "https://www.linkedin.com/",
          },
          { type: "text", value: " as a Junior Developer." },
        ],
        category: "work",
      },
    ],
  },
  2016: {
    id: "panfry",
    events: [
      {
        text: [
          { type: "text", value: "Shipped " },
          {
            type: "link",
            value: "Panfry",
            href: "https://github.com/",
          },
          {
            type: "text",
            value: " — a small recipe box for the meals I actually cook on repeat.",
          },
        ],
        category: "work",
      },
    ],
  },
  2017: {
    id: "omscs-start",
    events: [
      {
        text: [
          {
            type: "link",
            value: "Georgia Tech",
            href: "https://omscs.gatech.edu/",
          },
        ],
        category: "college",
      },
    ],
  },
  2018: {
    id: "synapse",
    events: [
      {
        text: [
          { type: "text", value: "Joined " },
          {
            type: "link",
            value: "Synapse Foundry",
            href: "https://www.linkedin.com/",
          },
          { type: "text", value: " as a Software Engineer." },
        ],
        category: "work",
      },
    ],
  },
  2019: {
    id: "longmile",
    events: [
      {
        text: [
          { type: "text", value: "Shipped " },
          {
            type: "link",
            value: "Longmile",
            href: "https://github.com/",
          },
          {
            type: "text",
            value:
              " — tracks my slow, patient return to running after a bad knee.",
          },
        ],
        category: "work",
      },
      {
        text: "Italy",
        image: "destinations/italy.jpg",
        category: "destination",
      },
    ],
  },
  2021: {
    id: "latentworks",
    events: [
      {
        text: [
          { type: "text", value: "Joined " },
          {
            type: "link",
            value: "Latentworks",
            href: "https://www.linkedin.com/",
          },
          { type: "text", value: " as a Full Stack Engineer." },
        ],
        category: "work",
      },
    ],
  },
  2022: {
    id: "spinebox",
    events: [
      {
        text: [
          { type: "text", value: "Shipped " },
          {
            type: "link",
            value: "Spinebox",
            href: "https://github.com/",
          },
          {
            type: "text",
            value: " — a shelf for the books I have actually finished this year.",
          },
        ],
        category: "work",
      },
    ],
  },
  2023: {
    id: "coinwell",
    events: [
      {
        text: [
          { type: "text", value: "Shipped " },
          {
            type: "link",
            value: "Coinwell",
            href: "https://github.com/",
          },
          {
            type: "text",
            value: " — a simple, honest look at where my money actually goes.",
          },
        ],
        category: "work",
      },
      {
        text: "Greece",
        image: "destinations/greece.jpg",
        category: "destination",
      },
    ],
  },
  2024: {
    id: "vercel-ship-2024",
    events: [
      {
        text: [
          { type: "text", value: "Joined " },
          {
            type: "link",
            value: "Cortexa Labs",
            href: "https://www.linkedin.com/",
          },
          { type: "text", value: " as a Senior Full Stack Engineer." },
        ],
        category: "work",
      },
      {
        text: "Attended Vercel Ship in New York.",
        image: "vercel-ship-2024.jpg",
      },
    ],
    globalEvents: [
      {
        name: "Vercel Ship 2024",
        place: "New York",
        date: "May 23, 2024",
        icon: "vercel",
      },
    ],
  },
  2025: {
    id: "vercel-ship-2025",
    events: [
      {
        text: "Attended CES 2025 — NVIDIA keynote.",
        video: "ces-2025-nvidia.mp4",
      },
      {
        text: "Attended Vercel Ship 2025 in New York.",
        image: "vercel-ship-2025.jpg",
      },
    ],
    globalEvents: [
      {
        name: "CES 2025 Keynote NVIDIA",
        place: "Las Vegas",
        date: "Jan 6, 2025",
        icon: "nvidia",
      },
      {
        name: "Vercel Ship 2025",
        place: "New York",
        date: "June 25, 2025",
        icon: "vercel",
      },
    ],
  },
  2026: {
    id: "today",
    events: [
      {
        text: "Attended Compile 26 — Michael Truell's opening keynote.",
        video: "compile-26.mp4",
      },
    ],
    globalEvents: [
      {
        name: "Compile 26",
        place: "Fort Mason, San Francisco",
        date: "Jun 22, 2026",
        icon: "cursor",
      },
      {
        name: "Grok Bot Founder Build Night",
        place: "Cursor HQ, San Francisco",
        date: "Aug 20, 2026",
        icon: "cursor",
      },
      {
        name: "Cursor Conversations London",
        place: "Central London",
        date: "Sep 15, 2026",
        icon: "cursor",
      },
      {
        name: "Vercel Ship 2026",
        place: "Palace of Fine Arts, San Francisco",
        date: "Oct 15, 2026",
        icon: "vercel",
      },
      {
        name: "Next.js Conf 2026",
        place: "Moscone West, San Francisco",
        date: "Nov 5, 2026",
        icon: "vercel",
      },
      {
        name: "AI Engineer Summit",
        place: "Marines' Memorial, San Francisco",
        date: "Nov 19, 2026",
        icon: "cursor",
      },
    ],
  },
}
registry/new-york/templates/portfolio-v3/lib/lifeline-data.ts
import type {
  LifelineEvent,
  LifelineLegendItem,
  LifelineMarker,
} from "../components/lifeline/types"

export const LIFELINE_CURRENT_YEAR = 2026

export type LifelineMilestone = Omit<LifelineMarker, "year">
export type LifelineMilestones = Record<number, LifelineMilestone>

export interface LifelineBirthday {
  month: number
  day: number
}

export interface LifelineRecord {
  slug: string
  name: string
  birthYear: number
  birthday?: LifelineBirthday
  endYear?: number
  description: string
  legend?: LifelineLegendItem[]
  markers: LifelineMarker[]
}

interface DefineLifelineInput {
  slug: string
  name: string
  birthYear: number
  birthday?: LifelineBirthday
  endYear?: number
  description: string
  legend?: LifelineLegendItem[]
  milestones: LifelineMilestones
}

export type LifelineTextOverrides = Record<number, string[]>

export function localizeLifelineMarkers(
  markers: LifelineMarker[],
  texts: LifelineTextOverrides,
): LifelineMarker[] {
  return markers.map((marker) => {
    const translated = texts[marker.year]
    if (!translated) return marker

    const events: LifelineEvent[] = marker.events.map((event, index) => {
      const text = translated[index]
      if (text === undefined) return event
      if (typeof event === "string") return text
      if (Array.isArray(event)) return event
      return { ...event, text }
    })

    return { ...marker, events }
  })
}

export function defineLifeline(input: DefineLifelineInput): LifelineRecord {
  const { milestones, ...record } = input
  const lastYear = input.endYear ?? LIFELINE_CURRENT_YEAR
  const markers: LifelineMarker[] = []
  const today = new Date()

  for (let year = input.birthYear; year <= lastYear; year++) {
    const milestone = milestones[year]
    const birthdayPending =
      year === LIFELINE_CURRENT_YEAR &&
      input.birthday &&
      (today.getMonth() + 1 < input.birthday.month ||
        (today.getMonth() + 1 === input.birthday.month &&
          today.getDate() < input.birthday.day))
    const age = birthdayPending ? year - input.birthYear - 1 : undefined

    markers.push(
      milestone
        ? { year, ...(age !== undefined && { age }), ...milestone }
        : {
            id: `year-${year}`,
            year,
            events: [],
            ...(age !== undefined && { age }),
          },
    )
  }

  return { ...record, markers }
}
registry/new-york/templates/portfolio-v3/lib/media.ts
import { existsSync } from "node:fs"
import { join } from "node:path"

import {
  IMAGE_EXTENSIONS,
  VIDEO_EXTENSIONS,
  createTemplateImage,
  createTemplateVideo,
  resolveLocalMediaFilename,
} from "@/lib/block-media"

import type {
  LifelineEvent,
  LifelineMarker,
} from "../components/lifeline/types"
import { defineLifeline } from "./lifeline-data"

import {
  jonDoeMeta,
  jonDoeMilestones,
  type JonEvent,
  type JonMilestone,
} from "./jon-doe"

export {
  IMAGE_EXTENSIONS,
  VIDEO_EXTENSIONS,
  resolveLocalMediaFilename,
} from "@/lib/block-media"

const TEMPLATE_ID = "portfolio-v3"
const CDN_ORIGIN = "https://uiception.com"

function resolveConsumerMediaUrl(
  kind: "images" | "videos",
  relPath: string,
  allowedExtensions: readonly string[],
): string | null {
  const normalized = relPath.replace(/^\/+/, "").replace(/\\/g, "/")
  const slash = normalized.lastIndexOf("/")
  const dirPart = slash >= 0 ? normalized.slice(0, slash) : ""
  const filename = slash >= 0 ? normalized.slice(slash + 1) : normalized
  const abs = join(process.cwd(), "public", kind, ...(dirPart ? [dirPart] : []))
  const resolved = resolveLocalMediaFilename(abs, filename, allowedExtensions)
  if (!resolved || resolved.endsWith(".gitkeep")) return null
  const urlBase = dirPart ? `/${kind}/${dirPart}` : `/${kind}`
  return `${urlBase}/${resolved}`
}

export function resolveTemplateImage(
  relPath: string,
  origin = CDN_ORIGIN,
): string {
  return (
    resolveConsumerMediaUrl("images", relPath, IMAGE_EXTENSIONS) ??
    createTemplateImage(TEMPLATE_ID, origin)(relPath)
  )
}

export function resolveTemplateVideo(
  relPath: string,
  origin = CDN_ORIGIN,
): string {
  return (
    resolveConsumerMediaUrl("videos", relPath, VIDEO_EXTENSIONS) ??
    createTemplateVideo(TEMPLATE_ID, origin)(relPath)
  )
}

function mediaRel(filename: string, fallbackFolder: string) {
  return filename.includes("/") ? filename : `${fallbackFolder}/${filename}`
}

function resolveEvent(event: JonEvent, origin: string): LifelineEvent {
  if (typeof event === "string" || Array.isArray(event)) return event

  const imageSrc = event.image
    ? resolveTemplateImage(mediaRel(event.image, "moments"), origin)
    : null
  const videoSrc = event.video
    ? resolveTemplateVideo(mediaRel(event.video, "moments"), origin)
    : null
  const alt =
    typeof event.text === "string" ? event.text : "Timeline moment"

  if (!imageSrc && !videoSrc && !event.category) {
    return typeof event.text === "string" || Array.isArray(event.text)
      ? (event.text as LifelineEvent)
      : { text: event.text }
  }

  return {
    text: event.text,
    ...(event.category ? { category: event.category } : {}),
    ...(imageSrc ? { image: { src: imageSrc, alt } } : {}),
    ...(videoSrc ? { video: { src: videoSrc, alt } } : {}),
  }
}

function resolveMilestone(
  milestone: JonMilestone,
  origin: string,
): {
  id: string
  events: LifelineEvent[]
  companies?: LifelineMarker["companies"]
  globalEvents?: LifelineMarker["globalEvents"]
} {
  const events = milestone.events.map((event) => resolveEvent(event, origin))

  return {
    id: milestone.id,
    events,
    ...(milestone.companies ? { companies: milestone.companies } : {}),
    ...(milestone.globalEvents?.length
      ? { globalEvents: milestone.globalEvents }
      : {}),
  }
}

export function getJonDoeLifeline(origin = CDN_ORIGIN) {
  const milestones: Record<number, ReturnType<typeof resolveMilestone>> = {}
  for (const [year, milestone] of Object.entries(jonDoeMilestones)) {
    milestones[Number(year)] = resolveMilestone(milestone, origin)
  }

  return defineLifeline({
    slug: jonDoeMeta.slug,
    name: jonDoeMeta.name,
    birthYear: jonDoeMeta.birthYear,
    endYear: jonDoeMeta.endYear,
    description: jonDoeMeta.description,
    legend: jonDoeMeta.legend,
    milestones,
  })
}

export function resolveAvatarSrc(origin = CDN_ORIGIN): string {
  const installDir = join(process.cwd(), "public")
  for (const ext of IMAGE_EXTENSIONS) {
    const filename = `avatar${ext}`
    if (existsSync(join(installDir, filename))) {
      return `/${filename}`
    }
  }

  return resolveTemplateImage("avatar.png", origin)
}
registry/new-york/templates/portfolio-v3/public/avatar.png.gitkeep
Place your avatar here as avatar.png (or avatar.jpg / avatar.webp). Delete this .gitkeep file after adding your photo.
registry/new-york/templates/portfolio-v3/public/images/destinations/japan.jpg.gitkeep
Place japan.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/images/destinations/italy.jpg.gitkeep
Place italy.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/images/destinations/greece.jpg.gitkeep
Place greece.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/images/moments/vercel-ship-2024.jpg.gitkeep
Place vercel-ship-2024.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/images/moments/vercel-ship-2025.jpg.gitkeep
Place vercel-ship-2025.jpg here (or .png / .webp). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/videos/moments/ces-2025-nvidia.mp4.gitkeep
Place ces-2025-nvidia.mp4 here (or .webm / .mov / .mkv). Delete this .gitkeep after adding the file.
registry/new-york/templates/portfolio-v3/public/videos/moments/compile-26.mp4.gitkeep
Place compile-26.mp4 here (or .webm / .mov / .mkv). Delete this .gitkeep after adding the file.

More from uiception

Browse all components in this library →