{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "attachment",
  "title": "Attachment Chip",
  "description": "A chat attachment: images as a thumbnail that sharpens from a blur once decoded, files as a typed chip. Upload progress is a ring that springs between reports; a failure shakes once and offers Retry; removing one lets its neighbours slide into the gap.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-chat-core.json",
    "https://eased.dev/r/eased-text-swap.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/attachment/attachment-chip.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useInView } from \"motion/react\";\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { fileTypeLabel, formatBytes, type Attachment } from \"../_primitives/chat-core/chat-core\";\nimport { TextSwap } from \"../_primitives/text-swap/text-swap\";\nimport { blur, enter, exitFast, fade, instant, reducedFade, smooth, snappy, useSafeReducedMotion } from \"../lib/motion\";\nimport { pressIcon } from \"../lib/press\";\n\nexport type AttachmentChipProps = {\n  attachment: Attachment;\n  /** Shows the remove button (top-right; on hover and focus with a fine pointer, always on touch). */\n  onRemove?: (id: string) => void;\n  /** Shows Retry on a failed upload. */\n  onRetry?: (id: string) => void;\n  /** `auto` (default): images as a thumbnail tile, everything else as a file chip. `file` forces the chip. */\n  variant?: \"auto\" | \"file\";\n  className?: string;\n};\n\nconst RING = 20;\nconst R = 8;\n\n/**\n * One attachment: a thumbnail for images, a typed chip for files. The thumbnail sharpens from\n * a 4px blur once it decodes; upload progress is a ring that springs between reported values\n * (and spins when the size is unknown); a failure shakes once and offers Retry.\n */\nexport function AttachmentChip({ attachment: a, onRemove, onRetry, variant = \"auto\", className = \"\" }: AttachmentChipProps) {\n  const reduced = useSafeReducedMotion();\n  const status = a.status ?? \"done\";\n  const shake = useShake(status === \"error\", reduced);\n  const image = variant === \"auto\" && a.kind === \"image\";\n  const src = a.previewUrl ?? a.url;\n  const progressText = a.progress !== undefined ? `${Math.round(a.progress * 100)}%` : \"\";\n  const label = `${a.name}${status === \"uploading\" ? `, uploading ${progressText}` : status === \"error\" ? `, failed: ${a.error ?? \"upload failed\"}` : \"\"}`;\n\n  const remove = onRemove && (\n    <button\n      type=\"button\"\n      aria-label={`Remove ${a.name}`}\n      onClick={() => onRemove(a.id)}\n      className={`absolute -top-1.5 -right-1.5 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-pill border border-line bg-surface-raised text-text-secondary shadow-resting hit-area hover-fine:text-text pointer-fine:opacity-0 pointer-fine:group-hover-fine:opacity-100 pointer-fine:focus-visible:opacity-100 pointer-fine:group-focus-within:opacity-100 ${pressIcon}`}\n    >\n      <svg viewBox=\"0 0 12 12\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" className=\"size-2.5\" aria-hidden>\n        <path d=\"M3 3l6 6M9 3l-6 6\" />\n      </svg>\n    </button>\n  );\n\n  if (image) {\n    return (\n      <motion.div ref={shake} role=\"group\" aria-label={label} className={`group relative size-14 shrink-0 ${className}`}>\n        <div\n          className={`relative size-full overflow-clip rounded-control bg-surface-sunken image-ring ${status === \"error\" ? \"ring-1 ring-danger ring-inset\" : \"\"}`}\n        >\n          {src && <Thumb src={src} reduced={reduced} />}\n          <AnimatePresence initial={false}>\n            {status !== \"done\" && (\n              <motion.div\n                key=\"veil\"\n                className=\"absolute inset-0 flex items-center justify-center rounded-[inherit] bg-surface/70 backdrop-blur-[2px]\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1, transition: reduced ? reducedFade : enter }}\n                exit={{ opacity: 0, transition: reduced ? reducedFade : { ...fade, delay: 0.25 } }}\n              >\n                {status === \"error\" ? (\n                  onRetry ? <RetryIcon name={a.name} onRetry={() => onRetry(a.id)} /> : <ErrorGlyph />\n                ) : (\n                  <ProgressRing value={a.progress} name={a.name} />\n                )}\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n        {remove}\n      </motion.div>\n    );\n  }\n\n  const meta =\n    status === \"uploading\"\n      ? `Uploading… ${progressText}`.trim()\n      : status === \"error\"\n        ? (a.error ?? \"Upload failed\")\n        : [fileTypeLabel(a), a.size !== undefined ? formatBytes(a.size) : null].filter(Boolean).join(\" · \");\n\n  return (\n    <motion.div\n      ref={shake}\n      role=\"group\"\n      aria-label={label}\n      className={`group relative grid h-14 w-56 max-w-full shrink-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-2.5 rounded-control border bg-surface p-1.5 pr-3 [--pad:6px] [--radius-outer:var(--eased-radius-control)] ${\n        status === \"error\" ? \"border-danger\" : \"border-line-subtle\"\n      } ${className}`}\n    >\n      <div\n        className={`relative flex size-11 items-center justify-center overflow-clip rounded-control-2xs transition-colors duration-150 ease-standard ${\n          status === \"error\" ? \"bg-danger-subtle text-danger-text\" : \"bg-surface-sunken text-text-secondary\"\n        }`}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={status}\n            className=\"flex items-center justify-center\"\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: `blur(${blur.soft})` }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\", transition: reduced ? reducedFade : { ...snappy, opacity: enter, filter: enter } }}\n            exit={{ opacity: 0, scale: reduced ? 1 : 0.9, filter: reduced ? \"blur(0px)\" : `blur(${blur.soft})`, transition: reduced ? reducedFade : exitFast }}\n          >\n            {status === \"uploading\" ? (\n              <ProgressRing value={a.progress} name={a.name} />\n            ) : status === \"error\" ? (\n              <ErrorGlyph />\n            ) : (\n              <span className=\"text-ui-2xs font-medium tracking-caps select-none\">{fileTypeLabel(a)}</span>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </div>\n      <div className=\"flex min-w-0 flex-col\">\n        <p className=\"truncate text-ui-sm font-medium text-text\" title={a.name}>\n          {a.name}\n        </p>\n        <div className=\"flex min-w-0 items-baseline gap-2 text-ui-xs\">\n          <span className={`truncate tabular-nums ${status === \"error\" ? \"text-danger-text\" : \"text-text-muted\"}`}>\n            <TextSwap swapKey={status}>{meta}</TextSwap>\n          </span>\n          {status === \"error\" && onRetry && (\n            <button\n              type=\"button\"\n              onClick={() => onRetry(a.id)}\n              className=\"shrink-0 cursor-pointer rounded-control-2xs font-medium text-text underline decoration-line-strong underline-offset-2 focus-ring hover-fine:decoration-text\"\n            >\n              Retry\n            </button>\n          )}\n        </div>\n      </div>\n      {remove}\n    </motion.div>\n  );\n}\n\n/* ---------- The list ---------------------------------------------------------- */\n\nexport type AttachmentListProps = {\n  attachments: Attachment[];\n  onRemove?: (id: string) => void;\n  onRetry?: (id: string) => void;\n  /** Accessible name of the list. Default \"Attachments\". */\n  label?: string;\n  className?: string;\n  /** Render something after the chips (e.g. an \"Add\" tile). */\n  children?: ReactNode;\n};\n\n/**\n * A wrapping row of chips. New chips pop in from 0.9; a removed chip shrinks away and its\n * neighbours slide into the gap. Nothing animates on first render.\n */\nexport function AttachmentList({ attachments, onRemove, onRetry, label = \"Attachments\", className = \"\", children }: AttachmentListProps) {\n  const reduced = useSafeReducedMotion();\n  return (\n    <ul aria-label={label} className={`relative flex flex-wrap items-end gap-2 ${className}`}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {attachments.map((a) => (\n          <motion.li\n            key={a.id}\n            layout={reduced ? false : \"position\"}\n            className=\"flex min-w-0\"\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n            animate={{ opacity: 1, scale: 1, transition: reduced ? reducedFade : { ...snappy, opacity: enter } }}\n            exit={{ opacity: 0, scale: reduced ? 1 : 0.9, transition: reduced ? reducedFade : exitFast }}\n            transition={reduced ? instant : smooth}\n          >\n            <AttachmentChip attachment={a} onRemove={onRemove} onRetry={onRetry} />\n          </motion.li>\n        ))}\n      </AnimatePresence>\n      {children}\n    </ul>\n  );\n}\n\n/* ---------- Parts ------------------------------------------------------------- */\n\nfunction Thumb({ src, reduced }: { src: string; reduced: boolean }) {\n  const [loaded, setLoaded] = useState(false);\n  const ref = useRef<HTMLImageElement>(null);\n  // A cached or data: image can finish before hydration attaches onLoad.\n  useEffect(() => {\n    const img = ref.current;\n    if (img?.complete && img.naturalWidth > 0) setLoaded(true);\n  }, [src]);\n  return (\n    <motion.img\n      ref={ref}\n      src={src}\n      alt=\"\"\n      decoding=\"async\"\n      draggable={false}\n      onLoad={() => setLoaded(true)}\n      className=\"absolute inset-0 size-full object-cover select-none dark:brightness-[.93] dark:saturate-[.96]\"\n      initial={false}\n      animate={loaded ? { opacity: 1, filter: \"blur(0px)\" } : { opacity: 0, filter: reduced ? \"blur(0px)\" : `blur(${blur.soft})` }}\n      transition={reduced ? reducedFade : { duration: 0.3, ease: [0.16, 1, 0.3, 1] }}\n    />\n  );\n}\n\n/** A 20px ring: springs to `value` (0–1), or spins a quarter arc while the size is unknown. */\nexport function ProgressRing({ value, name }: { value?: number; name: string }) {\n  const reduced = useSafeReducedMotion();\n  const ref = useRef<SVGSVGElement>(null);\n  const inView = useInView(ref);\n  const known = value !== undefined;\n  return (\n    <svg\n      ref={ref}\n      viewBox={`0 0 ${RING} ${RING}`}\n      className={`size-5 -rotate-90 text-brand ${known ? \"\" : \"animate-spin reduced:animate-none\"}`}\n      style={known ? undefined : { animationDuration: \"900ms\", animationPlayState: inView ? \"running\" : \"paused\" }}\n      role=\"progressbar\"\n      aria-label={`Uploading ${name}`}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={known ? Math.round(value * 100) : undefined}\n    >\n      <circle cx={RING / 2} cy={RING / 2} r={R} fill=\"none\" stroke=\"currentColor\" strokeOpacity={0.18} strokeWidth={2} />\n      <motion.circle\n        cx={RING / 2}\n        cy={RING / 2}\n        r={R}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={2}\n        strokeLinecap=\"round\"\n        initial={false}\n        animate={{ pathLength: known ? Math.max(0.02, value) : 0.25 }}\n        transition={reduced ? instant : smooth}\n      />\n    </svg>\n  );\n}\n\nfunction ErrorGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-4 text-danger-text\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden>\n      <circle cx=\"8\" cy=\"8\" r=\"6.25\" />\n      <path d=\"M8 4.75v3.5\" />\n      <path d=\"M8 11.1v.15\" strokeWidth={2} />\n    </svg>\n  );\n}\n\nfunction RetryIcon({ name, onRetry }: { name: string; onRetry: () => void }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={`Retry ${name}`}\n      onClick={onRetry}\n      className={`inline-flex size-8 cursor-pointer items-center justify-center rounded-pill bg-surface-raised text-danger-text shadow-resting hover-fine:text-text ${pressIcon}`}\n    >\n      <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n        <path d=\"M13 8a5 5 0 1 1-1.6-3.66\" />\n        <path d=\"M13 2.75v2.5h-2.5\" />\n      </svg>\n    </button>\n  );\n}\n\n/** Shakes the element once each time `on` turns true (rule: errors shake, never loop). */\nfunction useShake(on: boolean, reduced: boolean) {\n  const ref = useRef<HTMLDivElement>(null);\n  const was = useRef(on);\n  useEffect(() => {\n    if (on && !was.current && !reduced && ref.current) {\n      ref.current.animate(\n        [{ translate: \"0\" }, { translate: \"-3px\" }, { translate: \"3px\" }, { translate: \"-2px\" }, { translate: \"1px\" }, { translate: \"0\" }],\n        { duration: 320, easing: \"cubic-bezier(0.22, 1, 0.36, 1)\" },\n      );\n    }\n    was.current = on;\n  }, [on, reduced]);\n  return ref;\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/attachment/attachment-chip.tsx"
    }
  ],
  "docs": "Import AttachmentChip and AttachmentList from components/eased/attachment/attachment-chip. Pair with useAttachments from components/eased/_primitives/chat-core/chat-core.",
  "type": "registry:ui"
}