{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "attachment",
  "type": "registry:ui",
  "title": "Attachment",
  "description": "File and image attachment chips with thumbnails, upload progress rings, error/retry, remove, a “+N” overflow stack and a dropzone.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/kura-motion.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/attachment/attachment-utils.ts",
      "type": "registry:ui",
      "target": "components/kura/attachment/attachment-utils.ts",
      "content": "/** Upload lifecycle of one attachment. */\nexport type AttachmentStatus = \"ready\" | \"uploading\" | \"error\";\n\n/** Broad file family, used to pick an icon and a label. */\nexport type AttachmentKind =\n  | \"image\"\n  | \"pdf\"\n  | \"code\"\n  | \"text\"\n  | \"sheet\"\n  | \"audio\"\n  | \"video\"\n  | \"archive\"\n  | \"file\";\n\n/** Plain-data description of an attachment, as stored in app or composer state. */\nexport interface AttachmentItem {\n  /** Stable id, unique within the list. */\n  id: string;\n  /** File name shown on the chip, e.g. \"q3-report.pdf\". */\n  name: string;\n  /** Size in bytes. */\n  size?: number;\n  /** MIME type, e.g. \"image/png\". Used for the icon and the thumbnail decision. */\n  type?: string;\n  /** Preview URL (object URL or remote) for images. */\n  url?: string;\n  /** Default \"ready\". */\n  status?: AttachmentStatus;\n  /** Upload progress 0..1 while `status` is \"uploading\". Omit for indeterminate. */\n  progress?: number;\n  /** Error message shown when `status` is \"error\". */\n  error?: string;\n  /** The original File, when the attachment came from a picker, paste or drop. */\n  file?: File;\n}\n\nconst CODE_EXT =\n  /\\.(m?[jt]sx?|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|sql|html?|css|scss|vue|svelte|json|ya?ml|toml|xml)$/i;\n\n/** Classify a file by MIME type, falling back to its extension. */\nexport function attachmentKind(name: string, type = \"\"): AttachmentKind {\n  const t = type.toLowerCase();\n  const n = name.toLowerCase();\n  if (t.startsWith(\"image/\") || /\\.(png|jpe?g|gif|webp|avif|svg|heic)$/.test(n)) return \"image\";\n  if (t === \"application/pdf\" || n.endsWith(\".pdf\")) return \"pdf\";\n  if (t.startsWith(\"audio/\") || /\\.(mp3|wav|m4a|ogg|flac|webm)$/.test(n)) return \"audio\";\n  if (t.startsWith(\"video/\") || /\\.(mp4|mov|mkv|avi)$/.test(n)) return \"video\";\n  if (/zip|tar|gzip|rar|7z/.test(t) || /\\.(zip|tar|gz|tgz|rar|7z)$/.test(n)) return \"archive\";\n  if (/spreadsheet|excel|csv/.test(t) || /\\.(csv|tsv|xlsx?|numbers)$/.test(n)) return \"sheet\";\n  if (CODE_EXT.test(n)) return \"code\";\n  if (t.startsWith(\"text/\") || /\\.(md|mdx|txt|rtf|docx?)$/.test(n)) return \"text\";\n  return \"file\";\n}\n\n/** Short uppercase extension (\"PDF\", \"TSX\") or the kind when there is none. */\nexport function attachmentExtension(name: string): string {\n  const match = /\\.([a-z0-9]{1,5})$/i.exec(name);\n  return match?.[1] ? match[1].toUpperCase() : \"\";\n}\n\n/** 1536 → \"1.5 KB\". Uses binary units and at most one decimal, like Finder. */\nexport function formatBytes(bytes: number): string {\n  if (!Number.isFinite(bytes) || bytes < 0) return \"\";\n  if (bytes < 1024) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\", \"TB\"];\n  let value = bytes / 1024;\n  let unit = 0;\n  while (value >= 1024 && unit < units.length - 1) {\n    value /= 1024;\n    unit++;\n  }\n  return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unit]}`;\n}\n\n/**\n * Does `file` match an `accept` string like \"image/*,.pdf,text/csv\"? An empty or missing accept\n * string accepts everything (same semantics as `<input type=\"file\" accept>`).\n */\nexport function acceptsFile(file: Pick<File, \"name\" | \"type\">, accept?: string): boolean {\n  if (!accept) return true;\n  const name = file.name.toLowerCase();\n  const type = (file.type || \"\").toLowerCase();\n  return accept\n    .split(\",\")\n    .map((s) => s.trim().toLowerCase())\n    .filter(Boolean)\n    .some((rule) => {\n      if (rule.startsWith(\".\")) return name.endsWith(rule);\n      if (rule.endsWith(\"/*\")) return type.startsWith(rule.slice(0, -1));\n      return type === rule;\n    });\n}\n\nlet counter = 0;\n\n/**\n * Turn a File into an AttachmentItem. Images get an object URL preview — call\n * `URL.revokeObjectURL(item.url)` when you discard the item.\n */\nexport function createAttachment(\n  file: File,\n  overrides: Partial<AttachmentItem> = {},\n): AttachmentItem {\n  counter += 1;\n  const isImage = attachmentKind(file.name, file.type) === \"image\";\n  const url =\n    isImage && typeof URL !== \"undefined\" && typeof URL.createObjectURL === \"function\"\n      ? URL.createObjectURL(file)\n      : undefined;\n  return {\n    id: `att-${Date.now().toString(36)}-${counter}`,\n    name: file.name,\n    size: file.size,\n    type: file.type,\n    url,\n    status: \"ready\",\n    file,\n    ...overrides,\n  };\n}\n\n/** Files from a DataTransfer (drop or paste). Ignores non-file items such as dragged text. */\nexport function filesFromDataTransfer(data: DataTransfer | null): File[] {\n  if (!data) return [];\n  if (data.files && data.files.length > 0) return Array.from(data.files);\n  const out: File[] = [];\n  for (const item of Array.from(data.items ?? [])) {\n    if (item.kind === \"file\") {\n      const f = item.getAsFile();\n      if (f) out.push(f);\n    }\n  }\n  return out;\n}\n\n/** True when a drag carries files (not text or links). Safe to call during dragenter/dragover. */\nexport function dragHasFiles(data: DataTransfer | null): boolean {\n  if (!data) return false;\n  return Array.from(data.types ?? []).includes(\"Files\");\n}\n"
    },
    {
      "path": "registry/kura/attachment/attachment.tsx",
      "type": "registry:ui",
      "target": "components/kura/attachment/attachment.tsx",
      "content": "\"use client\";\n\nimport {\n  CircleAlert,\n  CloudUpload,\n  FileArchive,\n  FileAudio,\n  FileCode,\n  File as FileIcon,\n  FileImage,\n  FileSpreadsheet,\n  FileText,\n  FileVideo,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { springs } from \"@/lib/kura/motion\";\nimport {\n  type AttachmentItem,\n  type AttachmentKind,\n  type AttachmentStatus,\n  acceptsFile,\n  attachmentExtension,\n  attachmentKind,\n  dragHasFiles,\n  filesFromDataTransfer,\n  formatBytes,\n} from \"./attachment-utils\";\n\nconst KIND_ICON: Record<AttachmentKind, React.ComponentType<{ className?: string }>> = {\n  image: FileImage,\n  pdf: FileText,\n  code: FileCode,\n  text: FileText,\n  sheet: FileSpreadsheet,\n  audio: FileAudio,\n  video: FileVideo,\n  archive: FileArchive,\n  file: FileIcon,\n};\n\n// ---------------------------------------------------------------------------------------------\n// Progress ring\n// ---------------------------------------------------------------------------------------------\n\nexport interface AttachmentProgressRingProps extends Omit<React.ComponentProps<\"svg\">, \"children\"> {\n  /** 0..1. Omit for an indeterminate spinning arc. */\n  progress?: number;\n  /** Stroke width in px. Default 2. */\n  thickness?: number;\n}\n\n/** Circular upload progress. Inherits `currentColor`; the track is the same color at 20%. */\nexport function AttachmentProgressRing({\n  progress,\n  thickness = 2,\n  className,\n  ...props\n}: AttachmentProgressRingProps) {\n  const determinate = typeof progress === \"number\" && Number.isFinite(progress);\n  const value = determinate ? Math.min(1, Math.max(0, progress)) : 0.28;\n  const r = 12 - thickness / 2;\n  return (\n    <svg\n      data-slot=\"attachment-progress\"\n      data-indeterminate={!determinate || undefined}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      aria-hidden\n      className={cn(\n        \"-rotate-90 size-6 shrink-0\",\n        !determinate &&\n          \"animate-kura-spin [animation-duration:1.1s] motion-reduce:animate-kura-pulse\",\n        className,\n      )}\n      {...props}\n    >\n      <circle\n        cx=\"12\"\n        cy=\"12\"\n        r={r}\n        stroke=\"currentColor\"\n        strokeOpacity={0.2}\n        strokeWidth={thickness}\n      />\n      <circle\n        cx=\"12\"\n        cy=\"12\"\n        r={r}\n        stroke=\"currentColor\"\n        strokeWidth={thickness}\n        strokeLinecap=\"round\"\n        pathLength={100}\n        strokeDasharray=\"100\"\n        strokeDashoffset={100 - value * 100}\n        className=\"transition-[stroke-dashoffset] duration-(--kura-duration-slow) ease-kura-out motion-reduce:transition-none\"\n      />\n    </svg>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Attachment chip / tile\n// ---------------------------------------------------------------------------------------------\n\nexport interface AttachmentProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** File name, e.g. \"q3-report.pdf\". Truncated in the middle of long names via CSS ellipsis. */\n  name: string;\n  /** Size in bytes; shown as \"2.4 MB\". */\n  size?: number;\n  /** MIME type. Picks the file-type icon and decides whether `previewUrl` renders as a thumbnail. */\n  type?: string;\n  /** Image preview URL. With an image type (or `variant=\"tile\"`) the chip renders as a thumbnail. */\n  previewUrl?: string;\n  /** Upload state. Default \"ready\". */\n  status?: AttachmentStatus;\n  /** Upload progress 0..1 while uploading. Omit for an indeterminate ring. */\n  progress?: number;\n  /** Error message for `status=\"error\"`. Shown in the tooltip and to screen readers. */\n  error?: string;\n  /** `auto` = thumbnail tile for images with a preview, chip otherwise. Default \"auto\". */\n  variant?: \"auto\" | \"chip\" | \"tile\";\n  /** Show a remove (×) button and call this when pressed. */\n  onRemove?: () => void;\n  /** Show a \"Retry\" action while `status=\"error\"`. */\n  onRetry?: () => void;\n}\n\n/**\n * One attached file: a chip with a file-type icon, name and size, or a square thumbnail for\n * images. Shows upload progress as a ring, errors with a retry action, and a remove button.\n */\nexport function Attachment({\n  name,\n  size,\n  type,\n  previewUrl,\n  status = \"ready\",\n  progress,\n  error,\n  variant = \"auto\",\n  onRemove,\n  onRetry,\n  className,\n  title,\n  ...props\n}: AttachmentProps) {\n  const kind = attachmentKind(name, type);\n  const tile = variant === \"tile\" || (variant === \"auto\" && kind === \"image\" && !!previewUrl);\n  const Icon = KIND_ICON[kind];\n  const ext = attachmentExtension(name);\n  const sizeLabel = typeof size === \"number\" ? formatBytes(size) : \"\";\n  const pct =\n    typeof progress === \"number\" ? Math.round(Math.min(1, Math.max(0, progress)) * 100) : null;\n\n  let meta: React.ReactNode;\n  if (status === \"uploading\") meta = pct === null ? \"Uploading…\" : `Uploading · ${pct}%`;\n  else if (status === \"error\") meta = error ? \"Failed\" : \"Upload failed\";\n  else meta = [ext, sizeLabel].filter(Boolean).join(\" · \") || \"File\";\n\n  const description = `${name}${status === \"uploading\" ? `, uploading${pct !== null ? ` ${pct}%` : \"\"}` : \"\"}${\n    status === \"error\" ? `, upload failed${error ? `: ${error}` : \"\"}` : \"\"\n  }`;\n\n  const removeButton = onRemove && (\n    <button\n      type=\"button\"\n      data-slot=\"attachment-remove\"\n      aria-label={`Remove ${name}`}\n      onClick={onRemove}\n      className={cn(\n        \"-top-1.5 -right-1.5 absolute z-10 flex size-5 cursor-pointer items-center justify-center rounded-full bg-kura-surface-raised text-kura-fg-muted shadow-kura-sm outline-none\",\n        \"after:-inset-1 after:absolute after:rounded-full\",\n        \"scale-90 opacity-0 transition-[opacity,transform,color] duration-(--kura-duration-fast) ease-kura-out\",\n        \"group-hover/attachment:scale-100 group-hover/attachment:opacity-100 group-focus-within/attachment:scale-100 group-focus-within/attachment:opacity-100\",\n        \"pointer-coarse:scale-100 pointer-coarse:opacity-100 hover:text-kura-fg focus-visible:shadow-kura-focus active:scale-90 motion-reduce:scale-100\",\n      )}\n    >\n      <X className=\"size-3\" strokeWidth={2.25} aria-hidden />\n    </button>\n  );\n\n  if (tile) {\n    return (\n      <div\n        data-slot=\"attachment\"\n        data-variant=\"tile\"\n        data-kind={kind}\n        data-status={status}\n        title={title ?? (status === \"error\" && error ? error : name)}\n        className={cn(\n          \"group/attachment relative isolate inline-flex size-11 shrink-0 rounded-kura-lg bg-kura-surface-sunken shadow-kura-xs\",\n          className,\n        )}\n        {...props}\n      >\n        <span className=\"relative size-full overflow-hidden rounded-[inherit]\">\n          {previewUrl ? (\n            <img\n              src={previewUrl}\n              alt={name}\n              draggable={false}\n              className={cn(\n                \"size-full object-cover transition-[filter,opacity] duration-(--kura-duration-base) ease-kura-out\",\n                status !== \"ready\" && \"opacity-70 saturate-50\",\n              )}\n            />\n          ) : (\n            <span className=\"flex size-full items-center justify-center text-kura-fg-subtle\">\n              <Icon className=\"size-4\" aria-hidden />\n              <span className=\"sr-only\">{name}</span>\n            </span>\n          )}\n          {/* inner hairline so light images keep an edge */}\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_var(--kura-border-subtle)]\"\n          />\n          {status === \"uploading\" && (\n            <span\n              data-slot=\"attachment-overlay\"\n              className=\"absolute inset-0 flex animate-kura-fade-in items-center justify-center bg-kura-ink/35 text-kura-fg-inverted\"\n            >\n              <AttachmentProgressRing progress={progress} className=\"size-5\" />\n            </span>\n          )}\n          {status === \"error\" && (\n            <span\n              data-slot=\"attachment-overlay\"\n              className=\"absolute inset-0 flex animate-kura-fade-in items-center justify-center bg-kura-danger-subtle/85 text-kura-danger-text\"\n            >\n              {onRetry ? (\n                <button\n                  type=\"button\"\n                  data-slot=\"attachment-retry\"\n                  onClick={onRetry}\n                  aria-label={`Retry uploading ${name}`}\n                  className=\"flex size-full cursor-pointer items-center justify-center outline-none focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)]\"\n                >\n                  <CircleAlert className=\"size-4\" aria-hidden />\n                </button>\n              ) : (\n                <CircleAlert className=\"size-4\" aria-hidden />\n              )}\n            </span>\n          )}\n        </span>\n        <span className=\"sr-only\" role={status === \"error\" ? \"alert\" : undefined}>\n          {status !== \"ready\" ? description : \"\"}\n        </span>\n        {removeButton}\n      </div>\n    );\n  }\n\n  return (\n    <div\n      data-slot=\"attachment\"\n      data-variant=\"chip\"\n      data-kind={kind}\n      data-status={status}\n      title={title ?? (status === \"error\" && error ? error : name)}\n      className={cn(\n        \"group/attachment relative inline-flex h-11 min-w-0 max-w-64 shrink-0 items-center gap-2.5 rounded-kura-lg bg-kura-surface py-1 pr-3 pl-1 font-kura-sans shadow-kura-xs\",\n        \"transition-[background-color,box-shadow] duration-(--kura-duration-fast) ease-kura-out\",\n        status === \"error\" &&\n          \"shadow-[0_0_0_1px_color-mix(in_oklch,var(--kura-danger)_35%,transparent)]\",\n        className,\n      )}\n      {...props}\n    >\n      <span\n        data-slot=\"attachment-icon\"\n        className={cn(\n          \"relative flex size-9 shrink-0 items-center justify-center rounded-kura-md transition-colors duration-(--kura-duration-base)\",\n          status === \"error\"\n            ? \"bg-kura-danger-subtle text-kura-danger-text\"\n            : \"bg-kura-hover text-kura-fg-muted shadow-[inset_0_0_0_1px_var(--kura-border-subtle)]\",\n        )}\n      >\n        {status === \"uploading\" ? (\n          <>\n            <Icon className=\"size-3.5 opacity-60\" aria-hidden />\n            <AttachmentProgressRing\n              progress={progress}\n              className=\"absolute inset-1 size-7 text-kura-fg\"\n              thickness={1.75}\n            />\n          </>\n        ) : status === \"error\" ? (\n          <CircleAlert className=\"size-4\" aria-hidden />\n        ) : (\n          <Icon className=\"size-4\" aria-hidden />\n        )}\n      </span>\n      <span className=\"flex min-w-0 flex-col gap-0.5 leading-none\">\n        <span data-slot=\"attachment-name\" className=\"truncate font-medium text-[13px] text-kura-fg\">\n          {name}\n        </span>\n        <span\n          data-slot=\"attachment-meta\"\n          className={cn(\n            \"flex items-center gap-1 text-[11px] tabular-nums\",\n            status === \"error\" ? \"text-kura-danger-text\" : \"text-kura-fg-subtle\",\n          )}\n        >\n          <span aria-hidden>{meta}</span>\n          <span className=\"sr-only\" role={status === \"error\" ? \"alert\" : undefined}>\n            {status === \"error\" ? `Upload failed${error ? `: ${error}` : \"\"}` : \"\"}\n          </span>\n          {status === \"error\" && onRetry && (\n            <>\n              <span aria-hidden>·</span>\n              <button\n                type=\"button\"\n                data-slot=\"attachment-retry\"\n                onClick={onRetry}\n                aria-label={`Retry uploading ${name}`}\n                className=\"cursor-pointer rounded-kura-xs font-medium text-kura-fg underline decoration-kura-border-strong underline-offset-2 outline-none hover:decoration-current focus-visible:shadow-kura-focus\"\n              >\n                Retry\n              </button>\n            </>\n          )}\n        </span>\n      </span>\n      {removeButton}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// List with \"+N\" overflow stack\n// ---------------------------------------------------------------------------------------------\n\nexport interface AttachmentListProps extends Omit<React.ComponentProps<\"ul\">, \"children\"> {\n  /** The attachments to render, in order. */\n  items: readonly AttachmentItem[];\n  /** Show a remove button on each item. */\n  onRemove?: (id: string) => void;\n  /** Show \"Retry\" on items whose status is \"error\". */\n  onRetry?: (id: string) => void;\n  /** Show at most this many, then a \"+N\" stack. Clicking the stack reveals the rest. */\n  max?: number;\n  /** Forwarded to every Attachment. Default \"auto\". */\n  variant?: \"auto\" | \"chip\" | \"tile\";\n  /** Accessible name of the list. Default \"Attachments\". */\n  label?: string;\n}\n\n/**\n * A wrapping row of attachments. Items pop in, and the row closes the gap smoothly when one is\n * removed. Overflow beyond `max` collapses into a \"+N\" stack.\n */\nexport function AttachmentList({\n  items,\n  onRemove,\n  onRetry,\n  max,\n  variant = \"auto\",\n  label = \"Attachments\",\n  className,\n  ...props\n}: AttachmentListProps) {\n  const reduced = useReducedMotion();\n  const [expanded, setExpanded] = React.useState(false);\n  const limit = !expanded && typeof max === \"number\" && items.length > max ? max : items.length;\n  const visible = items.slice(0, limit);\n  const hidden = items.length - limit;\n\n  const motionProps = reduced\n    ? {\n        initial: { opacity: 0 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0, transition: { duration: 0.09 } },\n        transition: { duration: 0.15 },\n      }\n    : {\n        layout: \"position\" as const,\n        initial: { opacity: 0, scale: 0.92, filter: \"blur(2px)\" },\n        animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n        exit: { opacity: 0, scale: 0.92, filter: \"blur(2px)\", transition: { duration: 0.12 } },\n        transition: springs.snappy,\n      };\n\n  return (\n    <ul\n      data-slot=\"attachment-list\"\n      aria-label={label}\n      className={cn(\"flex min-w-0 flex-wrap items-center gap-2\", className)}\n      {...props}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {visible.map((item) => (\n          <motion.li key={item.id} className=\"flex\" {...motionProps}>\n            <Attachment\n              name={item.name}\n              size={item.size}\n              type={item.type}\n              previewUrl={item.url}\n              status={item.status}\n              progress={item.progress}\n              error={item.error}\n              variant={variant}\n              onRemove={onRemove ? () => onRemove(item.id) : undefined}\n              onRetry={onRetry ? () => onRetry(item.id) : undefined}\n            />\n          </motion.li>\n        ))}\n        {hidden > 0 && (\n          <motion.li key=\"__overflow\" className=\"flex\" {...motionProps}>\n            <button\n              type=\"button\"\n              data-slot=\"attachment-overflow\"\n              onClick={() => setExpanded(true)}\n              aria-label={`Show ${hidden} more ${hidden === 1 ? \"attachment\" : \"attachments\"}`}\n              title={items\n                .slice(limit)\n                .map((i) => i.name)\n                .join(\"\\n\")}\n              className={cn(\n                \"group/overflow relative isolate mr-2.5 flex h-11 min-w-11 cursor-pointer items-center justify-center rounded-kura-lg px-2.5 font-kura-sans font-medium text-[13px] text-kura-fg-muted tabular-nums outline-none\",\n                \"transition-[color,transform] duration-(--kura-duration-fast) ease-kura-out hover:text-kura-fg focus-visible:shadow-kura-focus active:scale-[0.97] motion-reduce:active:scale-100\",\n              )}\n            >\n              {/* the \"deck\": two cards peeking out behind the counter's face */}\n              <span\n                aria-hidden\n                className=\"-z-20 absolute inset-0 translate-x-[10px] scale-[0.78] rounded-[inherit] bg-kura-surface shadow-kura-xs transition-transform duration-(--kura-duration-base) ease-kura-out group-hover/overflow:translate-x-[13px] motion-reduce:transition-none\"\n              />\n              <span\n                aria-hidden\n                className=\"-z-10 absolute inset-0 translate-x-[5px] scale-[0.89] rounded-[inherit] bg-kura-surface shadow-kura-xs transition-transform duration-(--kura-duration-base) ease-kura-out group-hover/overflow:translate-x-[7px] motion-reduce:transition-none\"\n              />\n              <span\n                aria-hidden\n                className=\"absolute inset-0 rounded-[inherit] bg-kura-surface shadow-kura-xs\"\n              />\n              <span className=\"relative\">+{hidden}</span>\n            </button>\n          </motion.li>\n        )}\n      </AnimatePresence>\n    </ul>\n  );\n}\n\n// ---------------------------------------------------------------------------------------------\n// Dropzone\n// ---------------------------------------------------------------------------------------------\n\nexport interface AttachmentDropzoneProps\n  extends Omit<React.ComponentProps<\"div\">, \"title\" | \"onDrop\"> {\n  /** Called with accepted files, and the files rejected by `accept` / `maxSize`. */\n  onFiles: (files: File[], rejected: File[]) => void;\n  /** Same syntax as `<input accept>`, e.g. \"image/*,.pdf\". */\n  accept?: string;\n  /** Allow several files. Default true. */\n  multiple?: boolean;\n  /** Reject files larger than this many bytes. */\n  maxSize?: number;\n  disabled?: boolean;\n  /** Headline. Default \"Drop files or browse\". */\n  title?: React.ReactNode;\n  /** Hint under the headline, e.g. \"PNG, PDF or CSV up to 10 MB\". */\n  description?: React.ReactNode;\n}\n\n/**\n * A drop target and file picker in one: click or press Enter to browse, or drop files onto it.\n * Highlights only when a drag actually carries files.\n */\nexport function AttachmentDropzone({\n  onFiles,\n  accept,\n  multiple = true,\n  maxSize,\n  disabled = false,\n  title = \"Drop files or browse\",\n  description,\n  className,\n  ...props\n}: AttachmentDropzoneProps) {\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const depth = React.useRef(0);\n  const [dragging, setDragging] = React.useState(false);\n  const descId = React.useId();\n\n  const deliver = (list: File[]) => {\n    const files = multiple ? list : list.slice(0, 1);\n    const ok: File[] = [];\n    const bad: File[] = [];\n    for (const f of files) {\n      if (acceptsFile(f, accept) && (maxSize === undefined || f.size <= maxSize)) ok.push(f);\n      else bad.push(f);\n    }\n    if (ok.length || bad.length) onFiles(ok, bad);\n  };\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: drag events only; the inner <button> is the keyboard/pointer control.\n    <div\n      data-slot=\"attachment-dropzone\"\n      data-dragging={dragging || undefined}\n      data-disabled={disabled || undefined}\n      onDragEnter={(e) => {\n        if (disabled || !dragHasFiles(e.dataTransfer)) return;\n        e.preventDefault();\n        depth.current += 1;\n        setDragging(true);\n      }}\n      onDragOver={(e) => {\n        if (disabled || !dragHasFiles(e.dataTransfer)) return;\n        e.preventDefault();\n        e.dataTransfer.dropEffect = \"copy\";\n      }}\n      onDragLeave={() => {\n        depth.current = Math.max(0, depth.current - 1);\n        if (depth.current === 0) setDragging(false);\n      }}\n      onDrop={(e) => {\n        if (disabled) return;\n        e.preventDefault();\n        depth.current = 0;\n        setDragging(false);\n        deliver(filesFromDataTransfer(e.dataTransfer));\n      }}\n      className={cn(\n        \"group/dropzone relative isolate flex min-h-36 w-full rounded-kura-xl font-kura-sans\",\n        \"bg-kura-surface-sunken transition-[background-color] duration-(--kura-duration-base) ease-kura-out\",\n        \"data-dragging:bg-kura-hover\",\n        disabled && \"cursor-not-allowed opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      {/* dashed hairline drawn in SVG so the dash, radius and color are precise */}\n      <svg\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 size-full overflow-visible text-kura-border-strong transition-colors duration-(--kura-duration-base) group-data-dragging/dropzone:text-kura-fg-subtle\"\n      >\n        <rect\n          x=\"0.5\"\n          y=\"0.5\"\n          rx=\"15.5\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1\"\n          strokeDasharray=\"5 4\"\n          style={{ width: \"calc(100% - 1px)\", height: \"calc(100% - 1px)\" }}\n        />\n      </svg>\n      <button\n        type=\"button\"\n        disabled={disabled}\n        aria-describedby={description ? descId : undefined}\n        onClick={() => inputRef.current?.click()}\n        className=\"flex w-full cursor-pointer flex-col items-center justify-center gap-3 rounded-[inherit] p-6 text-center outline-none focus-visible:shadow-kura-focus disabled:cursor-not-allowed\"\n      >\n        <span\n          aria-hidden\n          className={cn(\n            \"flex size-10 items-center justify-center rounded-kura-lg bg-kura-surface text-kura-fg-muted shadow-kura-sm\",\n            \"transition-transform duration-(--kura-duration-base) ease-kura-out group-hover/dropzone:-translate-y-0.5 group-data-dragging/dropzone:-translate-y-1 group-data-dragging/dropzone:text-kura-fg motion-reduce:translate-y-0\",\n          )}\n        >\n          <CloudUpload className=\"size-[18px]\" strokeWidth={1.75} />\n        </span>\n        <span className=\"flex flex-col gap-1\">\n          <span className=\"font-medium text-kura-fg text-sm\">\n            {dragging ? \"Release to attach\" : title}\n          </span>\n          {description && (\n            <span id={descId} className=\"text-kura-fg-subtle text-xs\">\n              {description}\n            </span>\n          )}\n        </span>\n      </button>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        tabIndex={-1}\n        aria-hidden\n        className=\"hidden\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={(e) => {\n          deliver(Array.from(e.currentTarget.files ?? []));\n          e.currentTarget.value = \"\";\n        }}\n      />\n    </div>\n  );\n}\n"
    },
    {
      "path": "registry/kura/attachment/index.ts",
      "type": "registry:ui",
      "target": "components/kura/attachment/index.ts",
      "content": "export {\n  Attachment,\n  AttachmentDropzone,\n  type AttachmentDropzoneProps,\n  AttachmentList,\n  type AttachmentListProps,\n  AttachmentProgressRing,\n  type AttachmentProgressRingProps,\n  type AttachmentProps,\n} from \"./attachment\";\nexport {\n  type AttachmentItem,\n  type AttachmentKind,\n  type AttachmentStatus,\n  acceptsFile,\n  attachmentExtension,\n  attachmentKind,\n  createAttachment,\n  dragHasFiles,\n  filesFromDataTransfer,\n  formatBytes,\n} from \"./attachment-utils\";\n"
    }
  ],
  "categories": [
    "input",
    "file",
    "upload",
    "attachment",
    "image"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/attachment · Markdown for agents: https://kuro.abstergo.fyi/components/attachment.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "Show files the user attached to a prompt before sending (inside or above a composer).",
        "Display upload progress, failures and a retry action for files sent to a model.",
        "Render the files that belong to a sent message or an agent artifact list.",
        "A standalone drop area for documents to index, e.g. “Add files to this project”."
      ],
      "whenNotToUse": [
        "A complete prompt input with paste and drag-drop built in — use composer (it renders attachment for you).",
        "Rich previews of generated documents or code — use artifact-card or code-block.",
        "Audio recordings with playback — use audio-waveform with `peaks`."
      ]
    }
  }
}
