{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-request-lifecycle-preview",
  "title": "Request Lifecycle Preview",
  "author": "Cavemio",
  "description": "A persistent request card with Asked, Planned, Shipped and Answered scenes.",
  "files": [
    {
      "path": "registry/components/request-lifecycle-preview.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\nimport { lifecycleEase, useLifecycleReducedMotion } from \"./lifecycle-motion\";\n\nexport type RequestLifecycleStage =\n  \"asked\" | \"planned\" | \"shipped\" | \"answered\";\nexport interface RequestVoter {\n  name: string;\n  initials: string;\n  color: string;\n  image?: string;\n}\nexport interface RequestLifecycleData {\n  title: string;\n  description: string;\n  votes: number;\n  voters: readonly RequestVoter[];\n  activity: readonly { text: string; channel: string; voter: RequestVoter }[];\n  roadmap: { label: string; count: number; address: string };\n  release: { badge: string; date: string; title: string; description: string };\n  email: {\n    sender: string;\n    initials: string;\n    recipient: string;\n    time: string;\n    greeting: string;\n    message: string;\n  };\n}\nexport interface RequestLifecyclePreviewProps {\n  stage: RequestLifecycleStage;\n  data: RequestLifecycleData;\n  labels?: {\n    pending?: string;\n    planned?: string;\n    completed?: string;\n    votes?: string;\n    emailed?: string;\n    voters?: string;\n    justNow?: string;\n  };\n  className?: string;\n  style?: CSSProperties;\n  ariaLabel?: string;\n}\n\nfunction Avatar({ voter, size = 22 }: { voter: RequestVoter; size?: number }) {\n  const [failed, setFailed] = useState(false);\n  useEffect(() => setFailed(false), [voter.image]);\n  return voter.image && !failed ? (\n    <img\n      src={voter.image}\n      alt=\"\"\n      onError={() => setFailed(true)}\n      width={size}\n      height={size}\n      className=\"block shrink-0 rounded-full object-cover\"\n      style={{ width: size, height: size }}\n    />\n  ) : (\n    <span\n      className=\"flex shrink-0 items-center justify-center rounded-full font-semibold text-white\"\n      style={{\n        width: size,\n        height: size,\n        fontSize: size / 3,\n        background: voter.color,\n      }}\n    >\n      {voter.initials}\n    </span>\n  );\n}\n\nfunction StatusIcon({\n  stage,\n  size = 16,\n}: {\n  stage: RequestLifecycleStage;\n  size?: number;\n}) {\n  const id = useId();\n  const reduced = useLifecycleReducedMotion();\n  const completed = stage === \"shipped\" || stage === \"answered\";\n  const colorClass =\n    stage === \"asked\"\n      ? \"text-[var(--warning,var(--color-amber-600))]\"\n      : \"text-[var(--accent-ink,var(--foreground))]\";\n  const transition = reduced\n    ? \"none\"\n    : `transform 700ms ${lifecycleEase}, opacity 250ms ease-out`;\n  return (\n    <svg\n      aria-hidden=\"true\"\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      className={`shrink-0 ${colorClass}`}\n      style={{ transition: reduced ? \"none\" : \"color 500ms ease\" }}\n    >\n      <defs>\n        <clipPath id={id}>\n          <rect x=\"4.5\" y=\"4.5\" width=\"15\" height=\"15\" rx=\"5\" />\n        </clipPath>\n      </defs>\n      <rect\n        x=\"1.5\"\n        y=\"1.5\"\n        width=\"21\"\n        height=\"21\"\n        rx=\"8\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n      />\n      <rect\n        x=\"4.5\"\n        y=\"4.5\"\n        width=\"15\"\n        height=\"15\"\n        rx=\"5\"\n        fill=\"currentColor\"\n        opacity=\"0.17\"\n      />\n      <g clipPath={`url(#${id})`}>\n        <rect\n          x=\"4.5\"\n          y=\"4.5\"\n          width=\"15\"\n          height=\"18\"\n          rx=\"3\"\n          fill=\"currentColor\"\n          style={{\n            transform: `translateY(${stage === \"asked\" ? 15 : stage === \"planned\" ? 7.5 : 0}px)`,\n            transition,\n          }}\n        />\n      </g>\n      <rect\n        x=\"1\"\n        y=\"1\"\n        width=\"22\"\n        height=\"22\"\n        rx=\"8\"\n        fill=\"currentColor\"\n        style={{ opacity: completed ? 1 : 0, transition }}\n      />\n      <path\n        d=\"m8 12 2.6 2.7 5.3-5.7\"\n        fill=\"none\"\n        stroke=\"white\"\n        strokeWidth=\"2.1\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        style={{ opacity: completed ? 1 : 0, transition }}\n      />\n    </svg>\n  );\n}\n\nconst card =\n  \"rounded-[14px] bg-card p-5 ring-1 ring-border shadow-[0_32px_64px_-32px_rgba(0,0,0,0.3),0_10px_24px_-16px_rgba(0,0,0,0.16)]\";\nconst pill =\n  \"flex min-h-10 items-center gap-2.5 rounded-full bg-card py-2 pr-4 pl-2 ring-1 ring-border shadow-[0_1px_3px_rgba(0,0,0,0.08)]\";\n\n/** The request stays mounted while the supporting scenes move around it. */\nexport function RequestLifecyclePreview({\n  stage,\n  data,\n  labels,\n  className = \"\",\n  style,\n  ariaLabel = \"Request lifecycle preview\",\n}: RequestLifecyclePreviewProps) {\n  const grainId = useId();\n  const reduced = useLifecycleReducedMotion();\n  const [entered, setEntered] = useState(false);\n  const root = useRef<HTMLElement>(null);\n  const [animatedVotes, setAnimatedVotes] = useState(data.votes);\n  const [geometry, setGeometry] = useState({ offset: 0, height: 0 });\n  const stack = useRef<HTMLDivElement>(null);\n  const top = useRef<HTMLDivElement>(null);\n  const bottom = useRef<HTMLDivElement>(null);\n  const request = useRef<HTMLElement>(null);\n  const targetVotes = Math.max(0, Math.round(data.votes));\n  const votes = stage === \"asked\" && !reduced ? animatedVotes : targetVotes;\n  const voteLabel = labels?.votes ?? \"votes\";\n  const status =\n    stage === \"asked\"\n      ? (labels?.pending ?? \"Pending\")\n      : stage === \"planned\"\n        ? (labels?.planned ?? \"Planned\")\n        : (labels?.completed ?? \"Completed\");\n\n  useEffect(() => {\n    const node = root.current;\n    if (!node) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setEntered(true);\n          observer.disconnect();\n        }\n      },\n      { threshold: 0.25 },\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (stage !== \"asked\" || reduced || !entered) return;\n    const start = performance.now();\n    let frame = 0;\n    const update = (now: number) => {\n      const t = Math.min(1, (now - start) / 1200);\n      setAnimatedVotes(\n        Math.round(\n          Math.min(1, targetVotes) +\n            (targetVotes - Math.min(1, targetVotes)) * (1 - Math.pow(1 - t, 3)),\n        ),\n      );\n      if (t < 1) frame = requestAnimationFrame(update);\n    };\n    frame = requestAnimationFrame(update);\n    return () => cancelAnimationFrame(frame);\n  }, [stage, targetVotes, reduced, entered]);\n\n  useEffect(() => {\n    const measure = () => {\n      const above = top.current?.offsetHeight ?? 0;\n      const below = bottom.current?.offsetHeight ?? 0;\n      const height = request.current?.offsetHeight ?? 0;\n      // Preserve the stage anchors, centering only when copy needs more room.\n      const anchors = { asked: 84, planned: 0, shipped: -100, answered: 92 };\n      const offset =\n        above + height + below + 88 <= 540\n          ? anchors[stage]\n          : (above - below) / 2;\n      setGeometry({\n        offset,\n        height:\n          2 *\n            Math.max(height / 2 + above - offset, height / 2 + below + offset) +\n          88,\n      });\n    };\n    const observer = new ResizeObserver(measure);\n    for (const node of [\n      stack.current,\n      top.current,\n      bottom.current,\n      request.current,\n    ])\n      if (node) observer.observe(node);\n    measure();\n    return () => observer.disconnect();\n  }, [stage]);\n\n  const layerStyle = (\n    active: boolean,\n    above = true,\n    delay = 0,\n  ): CSSProperties => ({\n    opacity: active && (entered || reduced) ? 1 : 0,\n    transform: `translateY(${(active && entered) || reduced ? 0 : above ? 8 : -8}px)`,\n    transition: reduced\n      ? \"none\"\n      : `opacity 500ms ${lifecycleEase}, transform 500ms ${lifecycleEase}`,\n    transitionDelay: active && !reduced ? `${delay}ms` : \"0ms\",\n    pointerEvents: active ? \"auto\" : \"none\",\n  });\n  function layer(\n    active: boolean,\n    children: ReactNode,\n    above: boolean,\n    key: string,\n  ) {\n    return (\n      <div\n        key={key}\n        ref={active ? (above ? top : bottom) : undefined}\n        aria-hidden={!active}\n        inert={!active}\n        className={`absolute inset-x-0 ${above ? \"bottom-full pb-3\" : \"top-full pt-3\"}`}\n        style={layerStyle(active, above)}\n      >\n        {children}\n      </div>\n    );\n  }\n\n  return (\n    <section\n      ref={root}\n      aria-label={ariaLabel}\n      data-stage={stage}\n      className={`relative isolate grid w-full place-items-center overflow-hidden rounded-[14px] bg-muted px-5 text-foreground md:[--lifecycle-min-height:600px] ${className}`}\n      style={{\n        minHeight: `max(var(--lifecycle-min-height, 540px), ${geometry.height}px)`,\n        ...style,\n      }}\n    >\n      <svg\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 -z-10 h-full w-full opacity-[0.035] mix-blend-multiply\"\n      >\n        <filter id={grainId}>\n          <feTurbulence\n            type=\"fractalNoise\"\n            baseFrequency=\"0.8\"\n            numOctaves=\"3\"\n            stitchTiles=\"stitch\"\n          />\n        </filter>\n        <rect width=\"100%\" height=\"100%\" filter={`url(#${grainId})`} />\n      </svg>\n      <div\n        ref={stack}\n        className=\"relative w-full max-w-[380px]\"\n        style={{\n          transform: `translateY(${geometry.offset}px)`,\n          transition: reduced ? \"none\" : `transform 700ms ${lifecycleEase}`,\n        }}\n      >\n        {layer(\n          stage === \"asked\",\n          <ul className=\"m-0 grid list-none gap-2 p-0\">\n            {data.activity.map((activity, index) => (\n              <li\n                key={`${activity.channel}-${index}`}\n                className={pill}\n                style={layerStyle(stage === \"asked\", true, index * 140)}\n              >\n                <Avatar voter={activity.voter} />\n                <span className=\"min-w-0 flex-1 text-[13.5px] leading-5 tracking-[-0.01em]\">\n                  {activity.text}\n                </span>\n                <span className=\"shrink-0 text-[12px] text-muted-foreground\">\n                  {activity.channel}\n                </span>\n              </li>\n            ))}\n          </ul>,\n          true,\n          \"activity\",\n        )}\n        {layer(\n          stage === \"planned\",\n          <div className=\"flex flex-wrap items-center gap-2 px-1 text-[13px]\">\n            <StatusIcon stage=\"planned\" size={14} />\n            <span className=\"font-medium\">{data.roadmap.label}</span>\n            <span className=\"text-muted-foreground\">{data.roadmap.count}</span>\n            <span className=\"ml-auto break-all text-[12.5px] text-muted-foreground\">\n              {data.roadmap.address}\n            </span>\n          </div>,\n          true,\n          \"roadmap\",\n        )}\n        {layer(\n          stage === \"planned\",\n          <div className={pill}>\n            <span className=\"grid size-[22px] shrink-0 place-items-center rounded-full bg-primary/20 text-[var(--accent-ink,var(--foreground))]\">\n              <svg\n                aria-hidden=\"true\"\n                width=\"12\"\n                height=\"12\"\n                viewBox=\"0 0 18 18\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.5\"\n              >\n                <rect x=\"2\" y=\"3.5\" width=\"14\" height=\"11\" rx=\"2\" />\n                <path d=\"m2 5 7 4.5L16 5\" />\n              </svg>\n            </span>\n            <span className=\"text-[13.5px] tabular-nums\">\n              {labels?.emailed ?? \"Emailed\"} {targetVotes}{\" \"}\n              {labels?.voters ?? \"voters\"}\n            </span>\n            <span className=\"ml-auto text-[12px] text-muted-foreground\">\n              {labels?.justNow ?? \"just now\"}\n            </span>\n          </div>,\n          false,\n          \"notice\",\n        )}\n        {layer(\n          stage === \"shipped\",\n          <article className={card}>\n            <div className=\"flex items-center gap-2 text-[12px]\">\n              <span className=\"rounded-full bg-[color-mix(in_srgb,var(--success,var(--color-emerald-600))_10%,transparent)] px-2 py-0.5 font-medium text-[var(--success,var(--color-emerald-700))]\">\n                {data.release.badge}\n              </span>\n              <span className=\"text-muted-foreground\">{data.release.date}</span>\n            </div>\n            <h3 className=\"mt-3 text-base leading-[1.3] font-semibold tracking-[-0.02em]\">\n              {data.release.title}\n            </h3>\n            <p className=\"mt-1.5 text-[13.5px] leading-[1.5] tracking-[-0.01em] text-pretty text-muted-foreground\">\n              {data.release.description}\n            </p>\n            <div className=\"mt-4 flex items-center gap-2 rounded-[10px] bg-muted px-3 py-2\">\n              <StatusIcon stage=\"shipped\" size={14} />\n              <span className=\"text-[13px] font-medium\">{data.title}</span>\n              <span className=\"ml-auto shrink-0 text-[12.5px] text-muted-foreground\">\n                {targetVotes} {voteLabel}\n              </span>\n            </div>\n          </article>,\n          false,\n          \"release\",\n        )}\n        {layer(\n          stage === \"answered\",\n          <article className={card}>\n            <div className=\"flex items-center gap-2.5 border-b border-border pb-3.5\">\n              <Avatar\n                voter={{\n                  name: data.email.sender,\n                  initials: data.email.initials,\n                  color: \"var(--foreground)\",\n                }}\n                size={28}\n              />\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"text-[13.5px] font-semibold\">\n                  {data.email.sender}\n                </p>\n                <p className=\"text-[12.5px] text-muted-foreground\">\n                  {data.email.recipient}\n                </p>\n              </div>\n              <span className=\"text-[12px] text-muted-foreground\">\n                {data.email.time}\n              </span>\n            </div>\n            <div className=\"mt-3.5 grid gap-2 text-[13.5px] leading-[1.5] tracking-[-0.01em] text-foreground\">\n              <p>{data.email.greeting}</p>\n              <p>{data.email.message}</p>\n              <p className=\"border-l-2 border-border pl-3 font-medium text-foreground\">\n                {data.title}\n              </p>\n            </div>\n          </article>,\n          true,\n          \"email\",\n        )}\n        <article ref={request} data-request-card className={`relative ${card}`}>\n          <div className=\"flex items-center gap-2\">\n            <StatusIcon stage={stage} />\n            <span className=\"text-[13px] font-medium tracking-[-0.005em] text-foreground\">\n              {status}\n            </span>\n          </div>\n          <h3 className=\"mt-3 text-[18px] leading-[1.3] font-semibold tracking-[-0.02em]\">\n            {data.title}\n          </h3>\n          <p className=\"mt-1.5 text-sm leading-[1.5] tracking-[-0.01em] text-pretty text-muted-foreground\">\n            {data.description}\n          </p>\n          <div className=\"mt-5 flex items-center justify-between gap-3 border-t border-border pt-4\">\n            <div\n              className=\"flex -space-x-1.5\"\n              aria-label={data.voters.map((voter) => voter.name).join(\", \")}\n            >\n              {data.voters.map((voter, index) => (\n                <span\n                  key={`${voter.name}-${index}`}\n                  className=\"rounded-full ring-2 ring-white\"\n                >\n                  <Avatar voter={voter} />\n                </span>\n              ))}\n            </div>\n            <span\n              className=\"shrink-0 text-[13px] font-medium tracking-[-0.005em] tabular-nums\"\n              aria-label={`${targetVotes} ${voteLabel}`}\n            >\n              <span aria-hidden=\"true\">\n                {votes} {voteLabel}\n              </span>\n            </span>\n          </div>\n        </article>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/lifecycle-motion.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\nexport const lifecycleEase = \"cubic-bezier(0.22, 1, 0.36, 1)\";\nconst query = \"(prefers-reduced-motion: reduce)\";\nfunction subscribe(notify: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", notify);\n  return () => media.removeEventListener(\"change\", notify);\n}\nexport function useLifecycleReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => true,\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "license": "MIT",
    "cavemio": {
      "component": "request-lifecycle-preview",
      "exportName": "RequestLifecyclePreview",
      "usage": "import { RequestLifecyclePreview } from \"@/components/request-lifecycle-preview\";\n\n<RequestLifecyclePreview stage={stage} data={requestData} />",
      "accessibility": "Inactive scenes are inert and hidden from assistive technology. Reduced motion disables movement, fades and vote counting. Layout measures wrapping content to avoid clipping.",
      "previewNote": "Four lifecycle scenes with local demo content. Supply your own data and connect the selected stage to your application.",
      "categories": [
        "blocks"
      ],
      "documentation": {
        "useCases": [],
        "features": [],
        "states": [],
        "props": [
          {
            "name": "stage",
            "type": "RequestLifecycleStage",
            "description": "Required. Current stage of the request lifecycle."
          },
          {
            "name": "data",
            "type": "RequestLifecycleData",
            "description": "Required. Request title and description, votes, voters, activity, roadmap, release, and email content."
          },
          {
            "name": "labels",
            "type": "{ pending?: string; planned?: string; completed?: string; votes?: string; emailed?: string; voters?: string; justNow?: string; }",
            "description": "Optional. Translations for lifecycle status and supporting labels."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Optional. Additional CSS classes for the root element."
          },
          {
            "name": "style",
            "type": "CSSProperties",
            "description": "Optional. Inline styles for the root element."
          },
          {
            "name": "ariaLabel",
            "type": "string",
            "description": "Optional. Accessible name for the component."
          }
        ]
      }
    }
  },
  "type": "registry:component"
}