{"slug":"file-upload","name":"File Upload","description":"Two file upload patterns: an attachment workspace for mixed files, links, audio and media, plus a progress queue with retry and removal.","category":"blocks","source_url":"https://beui.dev/r/file-upload/raw","detail_url":"https://beui.dev/r/file-upload","raw_url":"https://beui.dev/r/file-upload/raw","page_url":"https://beui.dev/components/blocks/file-upload","markdown_url":"https://beui.dev/components/blocks/file-upload.md","published_at":"2026-06-18","updated_at":"2026-09-14","dependencies":["clsx","lucide-react","motion","react","react-dom","tailwind-merge"],"internal":["@/components/motion/file-upload","@/components/motion/tooltip","@/components/motion/tooltip-surface","@/lib/ease","@/lib/hooks/use-dismiss","@/lib/hooks/use-hover-gesture","@/lib/hooks/use-tap-gesture","@/lib/presence-gate","@/lib/touch","@/lib/utils"],"files":[{"path":"components/motion/file-upload.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/blocks/file-upload\n\nimport {\n  AlertCircle,\n  CheckCircle2,\n  FileArchive,\n  FileAudio,\n  FileCode2,\n  FileIcon,\n  FileImage,\n  FileSpreadsheet,\n  FileText,\n  FileVideo,\n  Loader2,\n  RotateCcw,\n  UploadCloud,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useId, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type FileUploadStatus = \"queued\" | \"uploading\" | \"success\" | \"error\";\nexport type FileUploadVariant = \"default\" | \"centered\";\n\nexport type FileUploadItem = {\n  id: string;\n  name: string;\n  size: number;\n  type?: string;\n  progress?: number;\n  status?: FileUploadStatus;\n  error?: string;\n  file?: File;\n};\n\nexport type FileUploadClassNames = {\n  root?: string;\n  dropzone?: string;\n  queue?: string;\n  item?: string;\n  leading?: string;\n  content?: string;\n  name?: string;\n  meta?: string;\n  progress?: string;\n  action?: string;\n};\n\nexport interface FileUploadProps {\n  value?: FileUploadItem[];\n  defaultValue?: FileUploadItem[];\n  onValueChange?: (items: FileUploadItem[]) => void;\n  onFilesAdded?: (items: FileUploadItem[], files: File[]) => void;\n  onRemove?: (item: FileUploadItem) => void;\n  onRetry?: (item: FileUploadItem) => void;\n  accept?: string;\n  multiple?: boolean;\n  maxFiles?: number;\n  disabled?: boolean;\n  variant?: FileUploadVariant;\n  title?: string;\n  description?: string;\n  browseLabel?: string;\n  className?: string;\n  classNames?: FileUploadClassNames;\n}\n\nconst ROW_TRANSITION = { duration: 0.22, ease: EASE_OUT } as const;\nconst FAST_TRANSITION = { duration: 0.16, ease: EASE_OUT } as const;\n\nconst STATUS_LABEL: Record<FileUploadStatus, string> = {\n  queued: \"Queued\",\n  uploading: \"Uploading\",\n  success: \"Uploaded\",\n  error: \"Failed\",\n};\n\nconst STATUS_TONE: Record<FileUploadStatus, string> = {\n  queued: \"text-muted-foreground\",\n  uploading: \"text-foreground\",\n  success: \"text-emerald-600 dark:text-emerald-400\",\n  error: \"text-destructive\",\n};\n\nfunction useControllableUpload({\n  value,\n  defaultValue,\n  onValueChange,\n}: {\n  value?: FileUploadItem[];\n  defaultValue?: FileUploadItem[];\n  onValueChange?: (items: FileUploadItem[]) => void;\n}) {\n  const [internalValue, setInternalValue] = useState(defaultValue ?? []);\n  const isControlled = value !== undefined;\n  const items = value ?? internalValue;\n\n  const setItems = useCallback(\n    (next: FileUploadItem[]) => {\n      if (!isControlled) {\n        setInternalValue(next);\n      }\n\n      onValueChange?.(next);\n    },\n    [isControlled, onValueChange],\n  );\n\n  return [items, setItems] as const;\n}\n\nfunction clampProgress(value: number | undefined, status: FileUploadStatus) {\n  if (status === \"success\") return 100;\n  if (value === undefined || Number.isNaN(value)) return 0;\n  return Math.max(0, Math.min(100, value));\n}\n\nfunction formatBytes(bytes: number) {\n  if (!Number.isFinite(bytes) || bytes <= 0) return \"0 B\";\n\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  const exponent = Math.min(\n    Math.floor(Math.log(bytes) / Math.log(1024)),\n    units.length - 1,\n  );\n  const value = bytes / 1024 ** exponent;\n\n  return `${value >= 10 || exponent === 0 ? value.toFixed(0) : value.toFixed(1)} ${\n    units[exponent]\n  }`;\n}\n\nfunction fileKind(item: FileUploadItem) {\n  const extension = item.name.includes(\".\")\n    ? item.name.split(\".\").pop()\n    : undefined;\n\n  if (extension) return extension.toUpperCase();\n  if (item.type) return item.type.split(\"/\").pop()?.toUpperCase();\n  return \"FILE\";\n}\n\nfunction getFileIcon(item: FileUploadItem) {\n  const extension = item.name.includes(\".\")\n    ? item.name.split(\".\").pop()?.toLowerCase()\n    : undefined;\n  const type = item.type ?? \"\";\n\n  if (type.startsWith(\"image/\")) return FileImage;\n  if (type.startsWith(\"video/\")) return FileVideo;\n  if (type.startsWith(\"audio/\")) return FileAudio;\n  if (\n    type.includes(\"zip\") ||\n    type.includes(\"compressed\") ||\n    [\"zip\", \"rar\", \"7z\", \"tar\", \"gz\"].includes(extension ?? \"\")\n  ) {\n    return FileArchive;\n  }\n  if (\n    type.includes(\"spreadsheet\") ||\n    type.includes(\"excel\") ||\n    [\"csv\", \"xls\", \"xlsx\"].includes(extension ?? \"\")\n  ) {\n    return FileSpreadsheet;\n  }\n  if (\n    type.includes(\"pdf\") ||\n    type.startsWith(\"text/\") ||\n    [\"pdf\", \"doc\", \"docx\", \"md\", \"txt\"].includes(extension ?? \"\")\n  ) {\n    return FileText;\n  }\n  if (\n    [\n      \"css\",\n      \"html\",\n      \"js\",\n      \"jsx\",\n      \"json\",\n      \"mdx\",\n      \"ts\",\n      \"tsx\",\n      \"xml\",\n      \"yaml\",\n      \"yml\",\n    ].includes(extension ?? \"\")\n  ) {\n    return FileCode2;\n  }\n\n  return FileIcon;\n}\n\nexport function createFileUploadItem(file: File, index = 0): FileUploadItem {\n  return {\n    id: `${Date.now()}-${index}-${file.name}`,\n    name: file.name,\n    size: file.size,\n    type: file.type,\n    progress: 0,\n    status: \"uploading\",\n    file,\n  };\n}\n\nfunction StatusIcon({\n  status,\n  reduce,\n}: {\n  status: FileUploadStatus;\n  reduce: boolean;\n}) {\n  const iconClassName = \"h-4 w-4\";\n\n  return (\n    <AnimatePresence mode=\"wait\" initial={false}>\n      <motion.span\n        key={status}\n        initial={\n          reduce\n            ? { opacity: 0 }\n            : { opacity: 0, transform: \"translateY(4px)\" }\n        }\n        animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n        exit={\n          reduce\n            ? { opacity: 0 }\n            : { opacity: 0, transform: \"translateY(-4px)\" }\n        }\n        transition={FAST_TRANSITION}\n        className={cn(\"grid h-6 w-6 place-items-center\", STATUS_TONE[status])}\n      >\n        {status === \"success\" ? (\n          <CheckCircle2 className={iconClassName} />\n        ) : status === \"error\" ? (\n          <AlertCircle className={iconClassName} />\n        ) : status === \"uploading\" ? (\n          <Loader2\n            className={cn(\n              iconClassName,\n              \"animate-spin\",\n              reduce && \"animate-none\",\n            )}\n          />\n        ) : (\n          <FileIcon className={iconClassName} />\n        )}\n        <span className=\"sr-only\">{STATUS_LABEL[status]}</span>\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n\nfunction FileUploadRow({\n  item,\n  onRemove,\n  onRetry,\n  classNames,\n}: {\n  item: FileUploadItem;\n  onRemove: (item: FileUploadItem) => void;\n  onRetry: (item: FileUploadItem) => void;\n  classNames?: FileUploadClassNames;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const status = item.status ?? \"queued\";\n  const progress = clampProgress(item.progress, status);\n  const progressRatio = progress / 100;\n  const showProgress = status === \"uploading\" || status === \"success\";\n  const LeadingIcon = getFileIcon(item);\n\n  return (\n    <motion.li\n      layout={!reduce}\n      initial={\n        reduce ? { opacity: 0 } : { opacity: 0, transform: \"translateY(8px)\" }\n      }\n      animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n      exit={\n        reduce ? { opacity: 0 } : { opacity: 0, transform: \"translateY(-6px)\" }\n      }\n      transition={ROW_TRANSITION}\n      className={cn(\n        \"relative overflow-hidden rounded-2xl border border-border bg-background p-3\",\n        classNames?.item,\n      )}\n    >\n      <div className=\"flex items-center gap-3\">\n        <div\n          className={cn(\n            \"grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-muted text-muted-foreground\",\n            classNames?.leading,\n          )}\n        >\n          <LeadingIcon className=\"h-5 w-5\" />\n        </div>\n\n        <div className={cn(\"min-w-0 flex-1\", classNames?.content)}>\n          <div className=\"flex items-start justify-between gap-3\">\n            <div className=\"min-w-0\">\n              <p\n                className={cn(\n                  \"truncate text-sm font-medium text-foreground\",\n                  classNames?.name,\n                )}\n              >\n                {item.name}\n              </p>\n              <p\n                className={cn(\n                  \"mt-0.5 text-xs text-muted-foreground\",\n                  classNames?.meta,\n                )}\n              >\n                {fileKind(item)} · {formatBytes(item.size)}\n                {status === \"error\" && item.error ? ` · ${item.error}` : null}\n              </p>\n            </div>\n\n            <div className=\"flex shrink-0 items-center gap-1\">\n              <StatusIcon status={status} reduce={reduce} />\n              {status === \"error\" ? (\n                <button\n                  type=\"button\"\n                  onClick={() => onRetry(item)}\n                  aria-label={`Retry ${item.name}`}\n                  className={cn(\n                    \"grid h-7 w-7 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground active:scale-95\",\n                    classNames?.action,\n                  )}\n                >\n                  <RotateCcw className=\"h-3.5 w-3.5\" />\n                </button>\n              ) : null}\n              <button\n                type=\"button\"\n                onClick={() => onRemove(item)}\n                aria-label={`Remove ${item.name}`}\n                className={cn(\n                  \"grid h-7 w-7 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground active:scale-95\",\n                  classNames?.action,\n                )}\n              >\n                <X className=\"h-3.5 w-3.5\" />\n              </button>\n            </div>\n          </div>\n\n          {showProgress ? (\n            <div\n              role=\"progressbar\"\n              aria-valuemin={0}\n              aria-valuemax={100}\n              aria-valuenow={Math.round(progress)}\n              aria-label={`${item.name} upload progress`}\n              className={cn(\n                \"mt-3 h-1.5 overflow-hidden rounded-full bg-muted\",\n                classNames?.progress,\n              )}\n            >\n              <motion.div\n                className={cn(\n                  \"h-full rounded-full\",\n                  status === \"success\"\n                    ? \"bg-emerald-500\"\n                    : \"bg-foreground\",\n                )}\n                style={{\n                  transformOrigin: \"left\",\n                  transform: reduce ? `scaleX(${progressRatio})` : undefined,\n                }}\n                initial={false}\n                animate={\n                  reduce ? undefined : { transform: `scaleX(${progressRatio})` }\n                }\n                transition={{ duration: 0.28, ease: EASE_OUT }}\n              />\n            </div>\n          ) : null}\n        </div>\n      </div>\n    </motion.li>\n  );\n}\n\nexport function FileUpload({\n  value,\n  defaultValue,\n  onValueChange,\n  onFilesAdded,\n  onRemove,\n  onRetry,\n  accept,\n  multiple = true,\n  maxFiles,\n  disabled = false,\n  variant = \"default\",\n  title = \"Drop files here\",\n  description = \"Add files to the upload queue\",\n  browseLabel = \"Browse\",\n  className,\n  classNames,\n}: FileUploadProps) {\n  const inputId = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const dragDepthRef = useRef(0);\n  const reduce = useReducedMotion() ?? false;\n  const [items, setItems] = useControllableUpload({\n    value,\n    defaultValue,\n    onValueChange,\n  });\n  const [dragging, setDragging] = useState(false);\n\n  const commit = useCallback(\n    (next: FileUploadItem[]) => {\n      setItems(next);\n    },\n    [setItems],\n  );\n\n  const addFiles = useCallback(\n    (incomingFiles: File[]) => {\n      if (disabled || incomingFiles.length === 0) return;\n\n      const remainingSlots =\n        maxFiles === undefined ? incomingFiles.length : maxFiles - items.length;\n      if (remainingSlots <= 0) return;\n\n      const files = incomingFiles.slice(\n        0,\n        multiple ? remainingSlots : Math.min(1, remainingSlots),\n      );\n      const added = files.map((file, index) => createFileUploadItem(file, index));\n\n      if (added.length === 0) return;\n\n      commit([...items, ...added]);\n      onFilesAdded?.(added, files);\n    },\n    [commit, disabled, items, maxFiles, multiple, onFilesAdded],\n  );\n\n  const removeItem = useCallback(\n    (item: FileUploadItem) => {\n      commit(items.filter((entry) => entry.id !== item.id));\n      onRemove?.(item);\n    },\n    [commit, items, onRemove],\n  );\n\n  const retryItem = useCallback(\n    (item: FileUploadItem) => {\n      const retryingItem = {\n        ...item,\n        error: undefined,\n        progress: 0,\n        status: \"uploading\" as const,\n      };\n\n      commit(\n        items.map((entry) => (entry.id === item.id ? retryingItem : entry)),\n      );\n      onRetry?.(retryingItem);\n    },\n    [commit, items, onRetry],\n  );\n\n  const resetDrag = useCallback(() => {\n    dragDepthRef.current = 0;\n    setDragging(false);\n  }, []);\n\n  const maxReached = maxFiles !== undefined && items.length >= maxFiles;\n  const centered = variant === \"centered\";\n\n  return (\n    <div className={cn(\"w-full space-y-3\", className, classNames?.root)}>\n      <input\n        ref={inputRef}\n        id={inputId}\n        type=\"file\"\n        aria-label=\"Upload files\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled || maxReached}\n        tabIndex={-1}\n        className=\"sr-only\"\n        onChange={(event) => {\n          addFiles(Array.from(event.currentTarget.files ?? []));\n          event.currentTarget.value = \"\";\n        }}\n      />\n\n      <button\n        type=\"button\"\n        disabled={disabled || maxReached}\n        data-dragging={dragging}\n        onClick={() => inputRef.current?.click()}\n        onDragEnter={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          dragDepthRef.current += 1;\n          setDragging(true);\n        }}\n        onDragOver={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          event.dataTransfer.dropEffect = \"copy\";\n          setDragging(true);\n        }}\n        onDragLeave={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);\n          if (dragDepthRef.current === 0) setDragging(false);\n        }}\n        onDrop={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          resetDrag();\n          addFiles(Array.from(event.dataTransfer.files));\n        }}\n        className={cn(\n          \"group relative flex w-full overflow-hidden rounded-3xl border border-dashed border-border bg-background outline-none\",\n          \"transition-[border-color,transform] duration-200 active:scale-[0.99]\",\n          \"hover:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"data-[dragging=true]:border-foreground\",\n          \"disabled:pointer-events-none disabled:opacity-55\",\n          centered\n            ? \"min-h-56 flex-col items-center justify-center gap-3 p-7 text-center\"\n            : \"items-center gap-4 p-5 text-left\",\n          classNames?.dropzone,\n        )}\n      >\n        <motion.span\n          aria-hidden=\"true\"\n          className={cn(\n            \"grid shrink-0 place-items-center bg-muted text-foreground\",\n            centered\n              ? \"h-16 w-16 rounded-[1.35rem] border border-border\"\n              : \"h-14 w-14 rounded-[1.25rem]\",\n          )}\n          animate={\n            reduce\n              ? undefined\n              : {\n                  transform: dragging\n                    ? \"translateY(-2px)\"\n                    : \"translateY(0px)\",\n                }\n          }\n          transition={FAST_TRANSITION}\n        >\n          <UploadCloud className={centered ? \"h-7 w-7\" : \"h-6 w-6\"} />\n        </motion.span>\n\n        <span className={cn(\"min-w-0\", centered ? \"max-w-xs\" : \"flex-1\")}>\n          <span\n            className={cn(\n              \"block font-semibold text-foreground\",\n              centered ? \"text-base\" : \"text-sm\",\n            )}\n          >\n            {maxReached ? \"Upload limit reached\" : title}\n          </span>\n          <span\n            className={cn(\n              \"block text-xs text-muted-foreground\",\n              centered ? \"mt-1 leading-5\" : \"mt-0.5\",\n            )}\n          >\n            {maxReached\n              ? `${items.length} of ${maxFiles} files added`\n              : description}\n          </span>\n        </span>\n\n        <span\n          className={cn(\n            \"shrink-0 rounded-full border border-border text-xs font-medium text-foreground transition-colors duration-150 group-hover:bg-muted\",\n            centered ? \"mt-1 px-4 py-2\" : \"px-3.5 py-2\",\n          )}\n        >\n          {browseLabel}\n        </span>\n      </button>\n\n      <ul className={cn(\"space-y-2\", classNames?.queue)}>\n        <AnimatePresence initial={false}>\n          {items.map((item) => (\n            <FileUploadRow\n              key={item.id}\n              item={item}\n              onRemove={removeItem}\n              onRetry={retryItem}\n              classNames={classNames}\n            />\n          ))}\n        </AnimatePresence>\n      </ul>\n    </div>\n  );\n}\n"},{"path":"components/motion/attachment-upload.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/blocks/file-upload\n\nimport {\n  AlertCircle,\n  Check,\n  ExternalLink,\n  FileImage,\n  Link as LinkIcon,\n  LoaderCircle,\n  Mic,\n  Paperclip,\n  Pause,\n  Play,\n  RotateCcw,\n  Upload,\n  X,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Tooltip } from \"@/components/motion/tooltip\";\nimport {\n  EASE_OUT,\n  SPRING_LAYOUT,\n  SPRING_PRESS,\n} from \"@/lib/ease\";\nimport { PresenceGate } from \"@/lib/presence-gate\";\nimport { cn } from \"@/lib/utils\";\n\nexport type AttachmentUploadKind = \"file\" | \"link\" | \"image\" | \"audio\";\nexport type AttachmentRejectReason = \"too-large\" | \"max-files\";\nexport type AttachmentUploadStatus =\n  | \"idle\"\n  | \"uploading\"\n  | \"complete\"\n  | \"failed\";\n\nexport type AttachmentUploadItem = {\n  id: string;\n  name: string;\n  kind: AttachmentUploadKind;\n  size?: number;\n  href?: string;\n  previewUrl?: string;\n  currentTime?: number;\n  duration?: number;\n  status?: AttachmentUploadStatus;\n  error?: string;\n  file?: File;\n};\n\nexport type AttachmentUploadClassNames = {\n  dropzone?: string;\n  list?: string;\n  row?: string;\n};\n\nexport interface AttachmentUploadProps {\n  value?: AttachmentUploadItem[];\n  defaultValue?: AttachmentUploadItem[];\n  onValueChange?: (items: AttachmentUploadItem[]) => void;\n  onFilesAdded?: (items: AttachmentUploadItem[], files: File[]) => void;\n  onFilesRejected?: (files: File[], reason: AttachmentRejectReason) => void;\n  onRemove?: (item: AttachmentUploadItem) => void;\n  onRetry?: (item: AttachmentUploadItem) => void;\n  playingId?: string;\n  onAudioToggle?: (item: AttachmentUploadItem) => void;\n  accept?: string;\n  multiple?: boolean;\n  maxFiles?: number;\n  maxFileSize?: number;\n  disabled?: boolean;\n  title?: string;\n  description?: string;\n  attachmentsLabel?: string;\n  className?: string;\n  classNames?: AttachmentUploadClassNames;\n}\n\nconst ITEM_TRANSITION = { duration: 0.2, ease: EASE_OUT } as const;\nconst DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024;\nconst UPLOAD_PROGRESS_MS = 900;\nconst UPLOAD_COMPLETE_HOLD_MS = 1000;\nconst REMOVE_PENDING_MS = 420;\n\nconst WAVEFORM_BARS = [\n  18, 31, 24, 39, 30, 43, 27, 18, 9, 29, 38, 24, 34, 18, 26, 37, 21, 14,\n  7, 11, 22, 35, 18, 26, 41, 29, 17, 33,\n].map((height, index) => ({ id: `wave-${index}-${height}`, height }));\n\nfunction useControllableList<T>({\n  value,\n  defaultValue,\n  onValueChange,\n}: {\n  value?: T[];\n  defaultValue?: T[];\n  onValueChange?: (items: T[]) => void;\n}) {\n  const [internalValue, setInternalValue] = useState(defaultValue ?? []);\n  const controlled = value !== undefined;\n  const items = value ?? internalValue;\n\n  const setItems = useCallback(\n    (next: T[]) => {\n      if (!controlled) setInternalValue(next);\n      onValueChange?.(next);\n    },\n    [controlled, onValueChange],\n  );\n\n  return [items, setItems] as const;\n}\n\nfunction formatBytes(bytes: number | undefined) {\n  if (bytes === undefined || !Number.isFinite(bytes) || bytes <= 0) {\n    return null;\n  }\n\n  const units = [\"B\", \"KB\", \"MB\", \"GB\"];\n  const exponent = Math.min(\n    Math.floor(Math.log(bytes) / Math.log(1024)),\n    units.length - 1,\n  );\n  const value = bytes / 1024 ** exponent;\n\n  return `${value >= 10 || exponent === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[exponent]}`;\n}\n\nfunction formatDuration(seconds: number | undefined) {\n  const safeSeconds = Math.max(0, Math.round(seconds ?? 0));\n  const minutes = Math.floor(safeSeconds / 60);\n  return `${minutes}:${String(safeSeconds % 60).padStart(2, \"0\")}`;\n}\n\nfunction formatMaxSize(bytes: number) {\n  const megabytes = bytes / (1024 * 1024);\n  return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)} MB`;\n}\n\nfunction inferKind(file: File): AttachmentUploadKind {\n  if (file.type.startsWith(\"image/\")) return \"image\";\n  if (file.type.startsWith(\"audio/\")) return \"audio\";\n  return \"file\";\n}\n\nfunction AttachmentIcon({ kind }: { kind: AttachmentUploadKind }) {\n  if (kind === \"link\") return <LinkIcon className=\"size-4\" />;\n  if (kind === \"image\") return <FileImage className=\"size-4\" />;\n  if (kind === \"audio\") return <Mic className=\"size-4\" />;\n  return <Paperclip className=\"size-4\" />;\n}\n\nfunction imageSource(item: AttachmentUploadItem) {\n  if (item.kind !== \"image\") return undefined;\n  return item.previewUrl ?? item.href;\n}\n\ntype RowActionState =\n  | \"idle\"\n  | \"uploading\"\n  | \"complete\"\n  | \"failed\"\n  | \"removing\";\n\nfunction RowAction({\n  label,\n  onClick,\n  state,\n  retryable = false,\n  reduce = false,\n}: {\n  label: string;\n  onClick: () => void;\n  state: RowActionState;\n  retryable?: boolean;\n  reduce?: boolean;\n}) {\n  if (state === \"uploading\") {\n    return <span aria-hidden=\"true\" className=\"size-9 shrink-0\" />;\n  }\n\n  if (state === \"complete\") {\n    return (\n      <Tooltip content=\"Upload complete\" side=\"top\" delay={100}>\n        <motion.span\n          role=\"status\"\n          aria-label={`Upload complete for ${label}`}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.75 }}\n          animate={{ opacity: 1, scale: 1 }}\n          transition={ITEM_TRANSITION}\n          className=\"grid size-9 shrink-0 place-items-center rounded-xl text-emerald-600 dark:text-emerald-400\"\n        >\n          <Check className=\"size-4\" />\n        </motion.span>\n      </Tooltip>\n    );\n  }\n\n  if (state === \"removing\") {\n    return (\n      <Tooltip content=\"Removing attachment\" side=\"top\" delay={100}>\n        <span\n          role=\"status\"\n          aria-label={`Removing ${label}`}\n          className=\"grid size-9 shrink-0 place-items-center rounded-xl text-muted-foreground\"\n        >\n          <motion.span\n            animate={reduce ? undefined : { rotate: 360 }}\n            transition={{\n              duration: 0.7,\n              ease: \"linear\",\n              repeat: Infinity,\n            }}\n            className=\"grid place-items-center\"\n          >\n            <LoaderCircle className=\"size-4\" />\n          </motion.span>\n        </span>\n      </Tooltip>\n    );\n  }\n\n  if (state === \"failed\") {\n    if (!retryable) {\n      return (\n        <Tooltip content=\"Upload failed\" side=\"top\" delay={100}>\n          <span\n            role=\"status\"\n            aria-label={`Upload failed for ${label}`}\n            className=\"grid size-9 shrink-0 place-items-center rounded-xl text-destructive\"\n          >\n            <AlertCircle className=\"size-4\" />\n          </span>\n        </Tooltip>\n      );\n    }\n\n    return (\n      <Tooltip content=\"Retry upload\" side=\"top\" delay={100}>\n        <motion.button\n          type=\"button\"\n          aria-label={`Retry ${label}`}\n          onClick={onClick}\n          whileTap={reduce ? undefined : { scale: 0.92 }}\n          transition={SPRING_PRESS}\n          className=\"grid size-9 shrink-0 place-items-center rounded-xl text-destructive outline-none transition-colors hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <RotateCcw className=\"size-4\" />\n        </motion.button>\n      </Tooltip>\n    );\n  }\n\n  return (\n    <Tooltip content=\"Remove attachment\" side=\"top\" delay={100}>\n      <motion.button\n        type=\"button\"\n        aria-label={`Remove ${label}`}\n        onClick={onClick}\n        whileTap={reduce ? undefined : { scale: 0.92 }}\n        transition={SPRING_PRESS}\n        className=\"grid size-9 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <X className=\"size-4\" />\n      </motion.button>\n    </Tooltip>\n  );\n}\n\nfunction ImageThumbnail({\n  item,\n  layoutId,\n  onPreview,\n  reduce,\n}: {\n  item: AttachmentUploadItem;\n  layoutId?: string;\n  onPreview: (item: AttachmentUploadItem) => void;\n  reduce: boolean;\n}) {\n  const src = imageSource(item);\n\n  if (!src) {\n    return (\n      <span\n        aria-hidden=\"true\"\n        className=\"grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground\"\n      >\n        <FileImage className=\"size-4\" />\n      </span>\n    );\n  }\n\n  return (\n    <Tooltip\n      side=\"top\"\n      delay={160}\n      wrapperClassName=\"shrink-0\"\n      className=\"rounded-xl p-1 shadow-xl\"\n      content={\n        <span className=\"block w-32\">\n          {/* biome-ignore lint/performance/noImgElement: Blob and remote previews keep this registry component framework-agnostic. */}\n          <img\n            src={src}\n            alt=\"\"\n            className=\"h-20 w-full rounded-lg object-cover\"\n          />\n          <span className=\"block px-1 pb-0.5 pt-1 text-center text-[10px] font-medium text-muted-foreground\">\n            Click to preview\n          </span>\n        </span>\n      }\n    >\n      <motion.button\n        type=\"button\"\n        aria-label={`Preview ${item.name}`}\n        onClick={(event) => {\n          event.currentTarget.blur();\n          onPreview(item);\n        }}\n        whileTap={reduce ? undefined : { scale: 0.94 }}\n        transition={SPRING_PRESS}\n        className=\"group/image relative size-9 shrink-0 overflow-hidden rounded-[10px] bg-muted outline-none ring-1 ring-border/70 focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        {/* biome-ignore lint/performance/noImgElement: Motion layout requires the image element and portable blob URLs. */}\n        <motion.img\n          layoutId={layoutId}\n          src={src}\n          alt=\"\"\n          className=\"size-full object-cover\"\n          transition={{ layout: SPRING_LAYOUT }}\n        />\n      </motion.button>\n    </Tooltip>\n  );\n}\n\nfunction ImagePreviewDialog({\n  item,\n  layoutId,\n  onClose,\n  reduce,\n}: {\n  item: AttachmentUploadItem | null;\n  layoutId?: string;\n  onClose: () => void;\n  reduce: boolean;\n}) {\n  const closeRef = useRef<HTMLButtonElement>(null);\n\n  useEffect(() => {\n    if (!item) return;\n\n    const previousFocus =\n      document.activeElement instanceof HTMLElement\n        ? document.activeElement\n        : null;\n    const previousOverflow = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    closeRef.current?.focus();\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") onClose();\n      if (event.key === \"Tab\") {\n        event.preventDefault();\n        closeRef.current?.focus();\n      }\n    };\n    document.addEventListener(\"keydown\", handleKeyDown);\n\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n      document.body.style.overflow = previousOverflow;\n      previousFocus?.focus();\n    };\n  }, [item, onClose]);\n\n  if (typeof document === \"undefined\") return null;\n\n  const src = item ? imageSource(item) : undefined;\n  const content =\n    item && src ? (\n      // The wrapper carries no box: both children are `fixed` and resolve\n      // against the viewport themselves. The scrim spans the viewport edges but\n      // paints a colour, and the layer that centres the image is inset off every\n      // edge. `PresenceGate` releases interaction in the same commit that starts\n      // the exit. See tests/fixed-overlay-edge-sampling.test.tsx.\n      <PresenceGate>\n        {({ isPresent, gate }) => (\n          <div\n            inert={!isPresent}\n            className=\"pointer-events-none fixed left-0 top-0 z-[10000] size-0\"\n          >\n            <motion.button\n              type=\"button\"\n              aria-label=\"Close image preview\"\n              tabIndex={-1}\n              className=\"pointer-events-auto fixed inset-0 size-full cursor-default bg-black/45 backdrop-blur-xl\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={reduce ? undefined : { opacity: 0 }}\n              transition={{ duration: reduce ? 0.1 : 0.2, ease: EASE_OUT }}\n              {...gate}\n              onClick={onClose}\n            />\n\n            <div className=\"fixed inset-4 flex items-center justify-center sm:inset-8\">\n              <motion.div\n                role=\"dialog\"\n                aria-modal=\"true\"\n                aria-label={`Preview of ${item.name}`}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={reduce ? undefined : { opacity: 0 }}\n                transition={ITEM_TRANSITION}\n                {...gate}\n                className=\"pointer-events-auto relative\"\n              >\n                {/* biome-ignore lint/performance/noImgElement: Motion layout requires the image element and portable blob URLs. */}\n                <motion.img\n                  layoutId={reduce ? undefined : layoutId}\n                  src={src}\n                  alt={item.name}\n                  className=\"max-h-[90vh] max-w-[90vw] rounded-2xl object-contain shadow-2xl\"\n                  transition={{ layout: SPRING_LAYOUT }}\n                />\n                <motion.button\n                  ref={closeRef}\n                  type=\"button\"\n                  aria-label=\"Close image preview\"\n                  onClick={onClose}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={\n                    reduce ? undefined : { opacity: 0, scale: 0.8 }\n                  }\n                  whileTap={reduce ? undefined : { scale: 0.92 }}\n                  transition={SPRING_PRESS}\n                  className=\"absolute -right-3 -top-3 grid size-9 place-items-center rounded-full bg-background text-foreground shadow-xl outline-none ring-1 ring-border/70 transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring\"\n                >\n                  <X className=\"size-4\" />\n                </motion.button>\n              </motion.div>\n            </div>\n          </div>\n        )}\n      </PresenceGate>\n    ) : null;\n\n  return createPortal(\n    reduce ? content : <AnimatePresence>{content}</AnimatePresence>,\n    document.body,\n  );\n}\n\nfunction AttachmentRow({\n  item,\n  playing,\n  uploading,\n  uploadComplete,\n  failed,\n  removing,\n  arrivalIndex,\n  imageLayoutId,\n  onAudioToggle,\n  onImagePreview,\n  onRemove,\n  onRetry,\n  reduce,\n  className,\n}: {\n  item: AttachmentUploadItem;\n  playing: boolean;\n  uploading: boolean;\n  uploadComplete: boolean;\n  failed: boolean;\n  removing: boolean;\n  arrivalIndex: number;\n  imageLayoutId?: string;\n  onAudioToggle?: (item: AttachmentUploadItem) => void;\n  onImagePreview: (item: AttachmentUploadItem) => void;\n  onRemove: (item: AttachmentUploadItem) => void;\n  onRetry?: (item: AttachmentUploadItem) => void;\n  reduce: boolean;\n  className?: string;\n}) {\n  const size = formatBytes(item.size);\n  const progress =\n    item.duration && item.duration > 0\n      ? Math.min(1, Math.max(0, (item.currentTime ?? 0) / item.duration))\n      : 0;\n  const actionState: RowActionState = removing\n    ? \"removing\"\n    : uploading\n      ? \"uploading\"\n      : uploadComplete\n        ? \"complete\"\n        : failed\n          ? \"failed\"\n          : \"idle\";\n  const arrivalDelay = Math.min(Math.max(arrivalIndex, 0), 5) * 0.055;\n  const rowTransition =\n    !reduce && arrivalIndex >= 0\n      ? {\n          ...SPRING_LAYOUT,\n          delay: arrivalDelay,\n          opacity: {\n            duration: 0.16,\n            ease: EASE_OUT,\n            delay: arrivalDelay,\n          },\n        }\n      : ITEM_TRANSITION;\n  const showUploadProgress = uploading || uploadComplete;\n  const uploadProgress = (\n    <motion.span\n      role=\"progressbar\"\n      aria-label={`Uploading ${item.name}`}\n      className=\"pointer-events-none absolute inset-0 -z-10 origin-left bg-emerald-400/25 dark:bg-emerald-500/20\"\n      initial={{ opacity: 1, scaleX: 0 }}\n      animate={{ opacity: 1, scaleX: 1 }}\n      exit={reduce ? undefined : { opacity: 0 }}\n      transition={{\n        duration: reduce ? 0.1 : UPLOAD_PROGRESS_MS / 1000,\n        ease: EASE_OUT,\n      }}\n    />\n  );\n\n  return (\n    <motion.li\n      layout={!reduce}\n      initial={\n        reduce\n          ? { opacity: 0 }\n          : arrivalIndex >= 0\n            ? { opacity: 0, y: -16, scale: 0.985 }\n            : { opacity: 0, y: 6 }\n      }\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      exit={reduce ? undefined : { opacity: 0, y: -4 }}\n      transition={rowTransition}\n      className={cn(\n        \"flex min-h-14 items-center gap-1 rounded-2xl bg-muted/70 p-1\",\n        className,\n      )}\n    >\n      <div className=\"relative isolate flex min-w-0 flex-1 items-center gap-3 self-stretch overflow-hidden rounded-xl bg-background px-2 py-1\">\n        {failed ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 -z-10 bg-destructive/10\"\n          />\n        ) : null}\n\n        {item.kind === \"image\" ? (\n          <ImageThumbnail\n            item={item}\n            layoutId={imageLayoutId}\n            onPreview={onImagePreview}\n            reduce={reduce}\n          />\n        ) : (\n          <span\n            aria-hidden=\"true\"\n            className=\"grid size-7 shrink-0 place-items-center text-muted-foreground\"\n          >\n            <AttachmentIcon kind={item.kind} />\n          </span>\n        )}\n\n        {item.kind === \"audio\" ? (\n          <>\n            <span className=\"w-9 shrink-0 text-xs tabular-nums text-muted-foreground\">\n              {formatDuration(item.currentTime)}\n            </span>\n            <span\n              aria-hidden=\"true\"\n              className=\"flex h-11 min-w-0 flex-1 items-center gap-[3px] overflow-hidden\"\n            >\n              {WAVEFORM_BARS.map((bar, index) => (\n                <motion.span\n                  key={bar.id}\n                  className={cn(\n                    \"w-[3px] shrink-0 rounded-full\",\n                    index / WAVEFORM_BARS.length <= progress\n                      ? \"bg-foreground\"\n                      : \"bg-muted-foreground/35\",\n                  )}\n                  style={{ height: bar.height }}\n                  animate={\n                    reduce || !playing\n                      ? undefined\n                      : { scaleY: [0.72, 1, 0.78] }\n                  }\n                  transition={{\n                    duration: 0.55,\n                    ease: EASE_OUT,\n                    repeat: Infinity,\n                    delay: index * 0.018,\n                  }}\n                />\n              ))}\n            </span>\n            <span className=\"w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground\">\n              {formatDuration(item.duration)}\n            </span>\n            <motion.button\n              type=\"button\"\n              aria-label={`${playing ? \"Pause\" : \"Play\"} ${item.name}`}\n              onClick={() => onAudioToggle?.(item)}\n              whileTap={{ scale: 0.94 }}\n              transition={SPRING_PRESS}\n              className=\"grid size-9 shrink-0 place-items-center rounded-full bg-foreground text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n            >\n              <AnimatePresence mode=\"wait\" initial={false}>\n                <motion.span\n                  key={playing ? \"pause\" : \"play\"}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n                  transition={ITEM_TRANSITION}\n                >\n                  {playing ? (\n                    <Pause className=\"size-4 fill-current\" />\n                  ) : (\n                    <Play className=\"size-4 translate-x-px fill-current\" />\n                  )}\n                </motion.span>\n              </AnimatePresence>\n            </motion.button>\n          </>\n        ) : (\n          <>\n            <span className=\"min-w-0 flex-1\">\n              <span className=\"block truncate text-sm font-medium text-foreground\">\n                {item.name}\n              </span>\n              {failed ? (\n                <span className=\"block truncate text-[11px] text-destructive\">\n                  {item.error ?? \"Upload failed\"}\n                </span>\n              ) : null}\n            </span>\n            <span className=\"shrink-0 text-xs text-muted-foreground\">\n              {item.kind === \"link\" ? \"Web\" : size}\n            </span>\n            {item.kind === \"link\" && item.href ? (\n              <a\n                href={item.href}\n                target=\"_blank\"\n                rel=\"noreferrer noopener\"\n                aria-label={`Open ${item.name}`}\n                className=\"grid size-8 shrink-0 place-items-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n              >\n                <ExternalLink className=\"size-4\" />\n              </a>\n            ) : null}\n          </>\n        )}\n\n        {reduce ? (\n          showUploadProgress ? (\n            uploadProgress\n          ) : null\n        ) : (\n          <AnimatePresence>\n            {showUploadProgress ? uploadProgress : null}\n          </AnimatePresence>\n        )}\n      </div>\n\n      <RowAction\n        label={item.name}\n        onClick={() => {\n          if (actionState === \"failed\") {\n            onRetry?.(item);\n            return;\n          }\n          onRemove(item);\n        }}\n        state={actionState}\n        retryable={onRetry !== undefined}\n        reduce={reduce}\n      />\n    </motion.li>\n  );\n}\n\nexport function AttachmentUpload({\n  value,\n  defaultValue,\n  onValueChange,\n  onFilesAdded,\n  onFilesRejected,\n  onRemove,\n  onRetry,\n  playingId,\n  onAudioToggle,\n  accept,\n  multiple = true,\n  maxFiles = 12,\n  maxFileSize = DEFAULT_MAX_FILE_SIZE,\n  disabled = false,\n  title = \"Drag and drop or browse files\",\n  description,\n  attachmentsLabel = \"Attachments\",\n  className,\n  classNames,\n}: AttachmentUploadProps) {\n  const inputId = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const dragDepthRef = useRef(0);\n  const ownedUrlsRef = useRef(new Set<string>());\n  const lifecycleTimersRef = useRef(\n    new Set<ReturnType<typeof setTimeout>>(),\n  );\n  const reduce = useReducedMotion() ?? false;\n  const [dragging, setDragging] = useState(false);\n  const [previewItem, setPreviewItem] =\n    useState<AttachmentUploadItem | null>(null);\n  const [uploadingIds, setUploadingIds] = useState<Set<string>>(\n    () => new Set(),\n  );\n  const [uploadCompleteIds, setUploadCompleteIds] = useState<Set<string>>(\n    () => new Set(),\n  );\n  const [removingIds, setRemovingIds] = useState<Set<string>>(\n    () => new Set(),\n  );\n  const [items, setItems] = useControllableList({\n    value,\n    defaultValue,\n    onValueChange,\n  });\n  const itemsRef = useRef(items);\n  itemsRef.current = items;\n\n  useEffect(\n    () => () => {\n      for (const url of ownedUrlsRef.current) URL.revokeObjectURL(url);\n      ownedUrlsRef.current.clear();\n      for (const timer of lifecycleTimersRef.current) {\n        clearTimeout(timer);\n      }\n      lifecycleTimersRef.current.clear();\n    },\n    [],\n  );\n\n  const maxReached = items.length >= maxFiles;\n  const scheduleLifecycle = useCallback(\n    (callback: () => void, delay: number) => {\n      const timer = setTimeout(() => {\n        lifecycleTimersRef.current.delete(timer);\n        callback();\n      }, delay);\n      lifecycleTimersRef.current.add(timer);\n    },\n    [],\n  );\n\n  const addFiles = useCallback(\n    (incomingFiles: File[]) => {\n      if (disabled || incomingFiles.length === 0) return;\n\n      const availableSlots = Math.max(0, maxFiles - items.length);\n      if (availableSlots === 0) {\n        onFilesRejected?.(incomingFiles, \"max-files\");\n        return;\n      }\n\n      const selectedFiles = incomingFiles.slice(\n        0,\n        multiple ? availableSlots : Math.min(1, availableSlots),\n      );\n      const oversized = selectedFiles.filter(\n        (file) => file.size > maxFileSize,\n      );\n      const accepted = selectedFiles.filter(\n        (file) => file.size <= maxFileSize,\n      );\n\n      if (oversized.length > 0) onFilesRejected?.(oversized, \"too-large\");\n      if (incomingFiles.length > selectedFiles.length) {\n        onFilesRejected?.(incomingFiles.slice(selectedFiles.length), \"max-files\");\n      }\n\n      const added = accepted.map((file, index) => {\n        const kind = inferKind(file);\n        const objectUrl = URL.createObjectURL(file);\n        ownedUrlsRef.current.add(objectUrl);\n\n        return {\n          id: `${Date.now()}-${index}-${file.name}`,\n          name: file.name,\n          kind,\n          size: file.size,\n          previewUrl: kind === \"image\" ? objectUrl : undefined,\n          href: objectUrl,\n          currentTime: kind === \"audio\" ? 0 : undefined,\n          duration: kind === \"audio\" ? 0 : undefined,\n          file,\n        };\n      });\n\n      if (added.length === 0) return;\n      setItems([...items, ...added]);\n      const addedIds = added.map((item) => item.id);\n      setUploadingIds((current) => new Set([...current, ...addedIds]));\n      scheduleLifecycle(\n        () => {\n          setUploadingIds((current) => {\n            const next = new Set(current);\n            for (const id of addedIds) next.delete(id);\n            return next;\n          });\n          setUploadCompleteIds(\n            (current) => new Set([...current, ...addedIds]),\n          );\n          scheduleLifecycle(() => {\n            setUploadCompleteIds((current) => {\n              const next = new Set(current);\n              for (const id of addedIds) next.delete(id);\n              return next;\n            });\n          }, UPLOAD_COMPLETE_HOLD_MS);\n        },\n        reduce ? 140 : UPLOAD_PROGRESS_MS,\n      );\n      onFilesAdded?.(added, accepted);\n    },\n    [\n      disabled,\n      items,\n      maxFileSize,\n      maxFiles,\n      multiple,\n      onFilesAdded,\n      onFilesRejected,\n      reduce,\n      scheduleLifecycle,\n      setItems,\n    ],\n  );\n\n  const finalizeRemove = useCallback(\n    (item: AttachmentUploadItem) => {\n      const ownedUrl = [item.previewUrl, item.href].find(\n        (url): url is string =>\n          url !== undefined && ownedUrlsRef.current.has(url),\n      );\n      if (ownedUrl) {\n        URL.revokeObjectURL(ownedUrl);\n        ownedUrlsRef.current.delete(ownedUrl);\n      }\n      setPreviewItem((current) =>\n        current?.id === item.id ? null : current,\n      );\n      setUploadingIds((current) => {\n        const next = new Set(current);\n        next.delete(item.id);\n        return next;\n      });\n      setUploadCompleteIds((current) => {\n        const next = new Set(current);\n        next.delete(item.id);\n        return next;\n      });\n      setItems(itemsRef.current.filter((entry) => entry.id !== item.id));\n      onRemove?.(item);\n    },\n    [onRemove, setItems],\n  );\n\n  const requestRemove = useCallback(\n    (item: AttachmentUploadItem) => {\n      if (removingIds.has(item.id)) return;\n\n      setRemovingIds((current) => new Set(current).add(item.id));\n      scheduleLifecycle(\n        () => {\n          finalizeRemove(item);\n          setRemovingIds((current) => {\n            const next = new Set(current);\n            next.delete(item.id);\n            return next;\n          });\n        },\n        reduce ? 140 : REMOVE_PENDING_MS,\n      );\n    },\n    [\n      finalizeRemove,\n      reduce,\n      removingIds,\n      scheduleLifecycle,\n    ],\n  );\n\n  const resetDrag = useCallback(() => {\n    dragDepthRef.current = 0;\n    setDragging(false);\n  }, []);\n  const closePreview = useCallback(() => setPreviewItem(null), []);\n\n  useEffect(() => {\n    if (\n      previewItem &&\n      !items.some((item) => item.id === previewItem.id)\n    ) {\n      setPreviewItem(null);\n    }\n  }, [items, previewItem]);\n\n  const uploadOrder = Array.from(uploadingIds);\n  const previewLayoutId = previewItem\n    ? `attachment-image-${previewItem.id}`\n    : undefined;\n\n  return (\n    <LayoutGroup id={inputId}>\n      <div className={cn(\"w-full\", className)}>\n      <input\n        ref={inputRef}\n        id={inputId}\n        type=\"file\"\n        aria-label=\"Upload attachments\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled || maxReached}\n        tabIndex={-1}\n        className=\"sr-only\"\n        onChange={(event) => {\n          addFiles(Array.from(event.currentTarget.files ?? []));\n          event.currentTarget.value = \"\";\n        }}\n      />\n\n      <motion.button\n        type=\"button\"\n        disabled={disabled || maxReached}\n        data-dragging={dragging}\n        animate={\n          reduce\n            ? undefined\n            : { scale: dragging ? 1.006 : 1 }\n        }\n        whileTap={reduce ? undefined : { scale: 0.995 }}\n        transition={SPRING_PRESS}\n        onClick={() => inputRef.current?.click()}\n        onDragEnter={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          dragDepthRef.current += 1;\n          setDragging(true);\n        }}\n        onDragOver={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          event.dataTransfer.dropEffect = \"copy\";\n          setDragging(true);\n        }}\n        onDragLeave={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);\n          if (dragDepthRef.current === 0) setDragging(false);\n        }}\n        onDrop={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          resetDrag();\n          addFiles(Array.from(event.dataTransfer.files));\n        }}\n        className={cn(\n          \"group relative isolate flex min-h-52 w-full flex-col items-center justify-center overflow-hidden rounded-[2rem] bg-muted/65 p-2 text-center outline-none\",\n          \"transition-colors duration-200 hover:bg-muted/85\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"data-[dragging=true]:bg-muted\",\n          \"disabled:pointer-events-none disabled:opacity-55\",\n          classNames?.dropzone,\n        )}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-2 -z-10 rounded-[1.5rem] border border-dashed border-muted-foreground/25 bg-background transition-[border-color,background-color] duration-200 group-hover:border-muted-foreground/45 group-data-[dragging=true]:border-foreground/65 group-data-[dragging=true]:bg-muted/20\"\n        />\n        <motion.span\n          aria-hidden=\"true\"\n          animate={\n            reduce\n              ? undefined\n              : {\n                  y: dragging ? -4 : 0,\n                  scale: dragging ? 1.08 : 1,\n                }\n          }\n          transition={ITEM_TRANSITION}\n          className=\"mb-3 grid size-11 place-items-center rounded-2xl bg-muted text-foreground transition-colors duration-200 group-hover:bg-muted/80 group-data-[dragging=true]:bg-foreground group-data-[dragging=true]:text-background\"\n        >\n          <Upload className=\"size-[18px]\" />\n        </motion.span>\n        <span className=\"text-sm font-semibold tracking-[-0.01em] text-foreground\">\n          {maxReached ? \"Attachment limit reached\" : title}\n        </span>\n        <span className=\"mt-1 text-xs leading-5 text-muted-foreground\">\n          {maxReached\n            ? `${items.length} of ${maxFiles} attachments added`\n            : description ?? `Maximum ${formatMaxSize(maxFileSize)} file size`}\n        </span>\n      </motion.button>\n\n      {items.length > 0 ? (\n        <section className=\"mt-8\" aria-labelledby={`${inputId}-attachments`}>\n          <h3\n            id={`${inputId}-attachments`}\n            className=\"text-sm font-semibold text-foreground\"\n          >\n            {attachmentsLabel}\n          </h3>\n\n          {items.length > 0 ? (\n            <ul className={cn(\"mt-3 space-y-2\", classNames?.list)}>\n              <AnimatePresence initial={uploadOrder.length > 0}>\n                {items.map((item) => (\n                  <AttachmentRow\n                    key={item.id}\n                    item={item}\n                    playing={playingId === item.id}\n                    uploading={\n                      uploadingIds.has(item.id) ||\n                      item.status === \"uploading\"\n                    }\n                    uploadComplete={\n                      uploadCompleteIds.has(item.id) ||\n                      item.status === \"complete\"\n                    }\n                    failed={item.status === \"failed\"}\n                    removing={removingIds.has(item.id)}\n                    arrivalIndex={uploadOrder.indexOf(item.id)}\n                    imageLayoutId={\n                      reduce ? undefined : `attachment-image-${item.id}`\n                    }\n                    onAudioToggle={onAudioToggle}\n                    onImagePreview={setPreviewItem}\n                    onRemove={requestRemove}\n                    onRetry={onRetry}\n                    reduce={reduce}\n                    className={classNames?.row}\n                  />\n                ))}\n              </AnimatePresence>\n            </ul>\n          ) : null}\n        </section>\n      ) : null}\n\n      <ImagePreviewDialog\n        item={previewItem}\n        layoutId={reduce ? undefined : previewLayoutId}\n        onClose={closePreview}\n        reduce={reduce}\n      />\n      </div>\n    </LayoutGroup>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/tooltip.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence } from \"motion/react\";\nimport {\n  cloneElement,\n  isValidElement,\n  type PointerEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TooltipSurface } from \"@/components/motion/tooltip-surface\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nexport interface TooltipProps {\n  content: ReactNode;\n  children?: ReactElement;\n  /** Existing trigger for controlled integrations such as chart cells. */\n  anchorRef?: RefObject<HTMLElement | SVGElement | null>;\n  /** Point within the anchor, as fractions of its rendered width and height. */\n  anchorPoint?: { x: number; y: number };\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  id?: string;\n  side?: Side;\n  /** Delay before showing (ms). Default 120. */\n  delay?: number;\n  className?: string;\n  /** Classes for the outer wrapper span. Use to fix baseline / fill parent. */\n  wrapperClassName?: string;\n}\n\n// Gap between trigger and tooltip, in px.\nconst GAP = 8;\n\n// Centering transform for the fixed-positioned anchor point, per side.\nconst anchorTransform: Record<Side, string> = {\n  top: \"translate(-50%, -100%)\",\n  bottom: \"translate(-50%, 0)\",\n  left: \"translate(-100%, -50%)\",\n  right: \"translate(0, -50%)\",\n};\n\nconst transformOrigin: Record<Side, string> = {\n  top: \"center bottom\",\n  bottom: \"center top\",\n  left: \"right center\",\n  right: \"left center\",\n};\n\n// Once any tooltip has just closed, neighbouring tooltips open without the\n// initial delay — moving along a toolbar feels instant after the first one.\nconst WARM_WINDOW_MS = 300;\nlet lastHiddenAt = 0;\n\nexport function Tooltip({\n  content,\n  children,\n  side = \"top\",\n  delay = 120,\n  className,\n  wrapperClassName,\n  anchorRef: externalAnchorRef,\n  anchorPoint,\n  open: controlledOpen,\n  onOpenChange,\n  id: providedId,\n}: TooltipProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const open = controlledOpen ?? internalOpen;\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (controlledOpen === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlledOpen, onOpenChange],\n  );\n  const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);\n  const generatedId = useId();\n  const id = providedId ?? generatedId;\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const wrapperRef = useRef<HTMLSpanElement>(null);\n  const anchorRef = externalAnchorRef ?? wrapperRef;\n  const hover = useHoverGesture();\n  const surfaceRef = useRef<HTMLSpanElement>(null);\n\n  // Anchor point in viewport coords, on the edge of the trigger facing `side`.\n  // Position:fixed means these viewport coords place the tooltip directly, so\n  // it escapes every ancestor's stacking context and overflow.\n  const place = useCallback(() => {\n    const el = anchorRef.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const cx = r.left + r.width * (anchorPoint?.x ?? 0.5);\n    const cy = r.top + r.height * (anchorPoint?.y ?? 0.5);\n    const point: Record<Side, { top: number; left: number }> = {\n      top: { top: (anchorPoint ? cy : r.top) - GAP, left: cx },\n      bottom: { top: (anchorPoint ? cy : r.bottom) + GAP, left: cx },\n      left: { top: cy, left: (anchorPoint ? cx : r.left) - GAP },\n      right: { top: cy, left: (anchorPoint ? cx : r.right) + GAP },\n    };\n    const next = point[side];\n    const width = surfaceRef.current?.offsetWidth ?? 0;\n    const height = surfaceRef.current?.offsetHeight ?? 0;\n    const dx = side === \"left\" ? width : side === \"right\" ? 0 : width / 2;\n    const dy = side === \"top\" ? height : side === \"bottom\" ? 0 : height / 2;\n    next.left = Math.max(GAP + dx, Math.min(next.left, window.innerWidth - GAP - width + dx));\n    next.top = Math.max(GAP + dy, Math.min(next.top, window.innerHeight - GAP - height + dy));\n    setCoords(previous => previous?.top === next.top && previous.left === next.left ? previous : next);\n  }, [side, anchorRef, anchorPoint]);\n\n  const positioned = coords !== null;\n  useLayoutEffect(() => {\n    if (!open) return;\n    place();\n    const observer = new ResizeObserver(place);\n    if (anchorRef.current) observer.observe(anchorRef.current);\n    if (positioned && surfaceRef.current) observer.observe(surfaceRef.current);\n    return () => observer.disconnect();\n  }, [open, place, anchorRef, positioned]);\n\n  const show = useCallback(() => {\n    if (timer.current) clearTimeout(timer.current);\n    const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;\n    timer.current = setTimeout(\n      () => {\n        place();\n        setOpen(true);\n      },\n      warm ? 0 : delay,\n    );\n  }, [delay, place, setOpen]);\n\n  const hide = useCallback(() => {\n    if (timer.current) {\n      clearTimeout(timer.current);\n      timer.current = null;\n    }\n    if (open) lastHiddenAt = Date.now();\n    setOpen(false);\n  }, [open, setOpen]);\n\n  // A finger never hovers, and Safari does not focus a button on tap either, so\n  // the label is only reachable if the tap itself opens the tooltip. A click\n  // carries no pointerType, so the pointerdown that preceded it is what says\n  // whether this was a tap; keyboard activation arrives with no pointerdown at\n  // all, and focus has already shown the label there.\n  const tap = useTapGesture<boolean>();\n\n  const toggleOnTap = useCallback(() => {\n    const gesture = tap.take();\n    if (!gesture || gesture.pointerType === \"mouse\") return;\n    if (gesture.state) {\n      hide();\n      return;\n    }\n    if (timer.current) clearTimeout(timer.current);\n    place();\n    setOpen(true);\n  }, [hide, place, tap, setOpen]);\n\n  // ...and closed again by the next tap that lands somewhere else. The label\n  // covers nothing interactive, so that tap passes through to what it hit.\n  useDismiss(open, hide, anchorRef);\n\n  // Keep the tooltip pinned to the trigger while it's open and the page scrolls\n  // or resizes (fixed coords are viewport-relative).\n  useEffect(() => {\n    if (!open) return;\n    const onMove = () => place();\n    window.addEventListener(\"scroll\", onMove, true);\n    window.addEventListener(\"resize\", onMove);\n    return () => {\n      window.removeEventListener(\"scroll\", onMove, true);\n      window.removeEventListener(\"resize\", onMove);\n    };\n  }, [open, place]);\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  if (!externalAnchorRef && !isValidElement(children)) return children;\n\n  // The label describes the trigger, so it has to name the trigger itself.\n  // Everything else the tooltip needs is read off the anchor below instead of\n  // cloned on: a handler written onto the child is the child's handler as far\n  // as that child can tell, and a component that owns its activation —\n  // hard-wiring onClick and spreading the rest of its props over it, as\n  // ThemeToggle does — then runs the tooltip's instead of its own. Composing\n  // with `props.onClick` cannot save it either, because a component element's\n  // props hold nothing the component does internally.\n  const trigger = isValidElement(children)\n    ? cloneElement(children as ReactElement<Record<string, unknown>>, {\n        \"aria-describedby\": id,\n      })\n    : null;\n\n  return (\n    <>\n      {!externalAnchorRef ? (\n        // biome-ignore lint/a11y/noStaticElementInteractions: This wrapper observes bubbling trigger events without replacing the control's handlers.\n        <span\n          ref={wrapperRef}\n          className={cn(\"relative inline-flex align-middle\", wrapperClassName)}\n          // Pointer events, not the mouse pair: a tap fires compatibility\n          // mouseenter/mouseleave that carry no pointerType, which raced the tap\n          // path into opening and closing the same label.\n          onPointerEnter={(event: PointerEvent) => {\n            if (hover.enter(event)) show();\n          }}\n          onPointerLeave={(event: PointerEvent) => {\n            if (hover.leave(event)) hide();\n          }}\n          onFocus={show}\n          onBlur={hide}\n          onPointerDown={(event: PointerEvent) => tap.start(event, open)}\n          // A gesture the platform took away sends no click, and a key press\n          // starts an activation that never had a pointer behind it. Either way\n          // the record has to go, or the next click reads a finger that has long\n          // since lifted.\n          onPointerCancel={tap.drop}\n          onKeyDown={tap.drop}\n          onClick={toggleOnTap}\n        >\n          {trigger}\n        </span>\n      ) : null}\n      {typeof document !== \"undefined\"\n        ? createPortal(\n            <AnimatePresence>\n              {open && coords ? (\n                <span\n                  className=\"pointer-events-none fixed z-[9999]\"\n                  style={{\n                    top: coords.top,\n                    left: coords.left,\n                    transform: anchorTransform[side],\n                  }}\n                >\n                  <TooltipSurface\n                    ref={surfaceRef}\n                    id={id}\n                    side={side}\n                    style={{ transformOrigin: transformOrigin[side], maxWidth: \"calc(100vw - 16px)\", whiteSpace: \"normal\" }}\n                    className={className}\n                  >\n                    {content}\n                  </TooltipSurface>\n                </span>\n              ) : null}\n            </AnimatePresence>,\n            document.body,\n          )\n        : null}\n    </>\n  );\n}\n"},{"path":"lib/presence-gate.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface PresenceGateRenderProps {\n  /**\n   * False from the render that starts the exit animation onward. An overlay\n   * kept in the tree by `AnimatePresence` is still the topmost thing on the\n   * page, so anything it decides from `open` alone stays true for the whole\n   * exit — this is the boolean that already knows the overlay is leaving.\n   */\n  isPresent: boolean;\n  /**\n   * Spread onto every layer that takes pointer events while the overlay is\n   * open. Interaction releases in the same commit that starts the exit while\n   * the visual exit keeps playing: pointer events stop landing, and `inert`\n   * drops the subtree from focus order, from tab order and from the\n   * accessibility tree — an exiting dialog is not a dialog you can still type\n   * into. A layer that never takes pointer events (a wrapper that only centres\n   * the panel) takes `inert={!isPresent}` alone, so its own\n   * `pointer-events-none` is not overwritten.\n   */\n  gate: {\n    inert: boolean;\n    style: { pointerEvents: \"auto\" | \"none\" };\n  };\n}\n\nexport interface PresenceGateProps {\n  children: (props: PresenceGateRenderProps) => ReactNode;\n}\n\n/**\n * Reads the presence of the subtree it renders and hands it down.\n *\n * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the\n * components that own an overlay render the `AnimatePresence` themselves, so\n * the boolean has to be read one component further down: this is that\n * component, and the render prop is how it reaches the layers.\n */\nexport function PresenceGate({ children }: PresenceGateProps) {\n  const isPresent = useIsPresent();\n\n  return children({\n    isPresent,\n    gate: {\n      inert: !isPresent,\n      style: { pointerEvents: isPresent ? \"auto\" : \"none\" },\n    },\n  });\n}\n"},{"path":"components/motion/tooltip-surface.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useReducedMotion, type Variants } from \"motion/react\";\nimport { useMemo, type ComponentProps, type ReactNode, type Ref } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\n// Offset is in the direction *away* from the trigger — content originates near\n// the trigger and rises into resting position.\nconst offsetFrom: Record<Side, { x?: number; y?: number }> = {\n  top: { y: 8 },\n  bottom: { y: -8 },\n  left: { x: 8 },\n  right: { x: -8 },\n};\n\n// Small tooltip surfaces need the lighter spawn used by the original Tooltip.\nconst TOOLTIP_SPRING = { type: \"spring\", stiffness: 380, damping: 30, mass: 0.7 } as const;\n\nfunction buildVariants(side: Side): Variants {\n  const o = offsetFrom[side];\n  return {\n    initial: {\n      opacity: 0,\n      scale: 0.9,\n      filter: \"blur(5px)\",\n      x: o.x ?? 0,\n      y: o.y ?? 0,\n    },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      x: 0,\n      y: 0,\n      transition: {\n        ...TOOLTIP_SPRING,\n        opacity: { duration: 0.14, ease: EASE_OUT },\n        filter: { duration: 0.18, ease: EASE_OUT },\n      },\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.94,\n      filter: \"blur(3px)\",\n      x: (o.x ?? 0) * 0.6,\n      y: (o.y ?? 0) * 0.6,\n      transition: { duration: 0.12, ease: EASE_OUT },\n    },\n  };\n}\n\nconst REDUCED_VARIANTS: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },\n  exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },\n};\n\n/** The shared visual surface for trigger tooltips and chart readouts. Positioning belongs to the caller. */\nexport function TooltipSurface({\n  children,\n  side = \"top\",\n  className,\n  ref,\n  ...props\n}: Omit<ComponentProps<typeof motion.span>, \"children\"> & {\n  children?: ReactNode;\n  side?: Side;\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const reduce = useReducedMotion();\n  const variants = useMemo(() => reduce ? REDUCED_VARIANTS : buildVariants(side), [reduce, side]);\n  return (\n    <motion.span\n      ref={ref}\n      role=\"tooltip\"\n      variants={variants}\n      initial=\"initial\"\n      animate=\"animate\"\n      exit=\"exit\"\n      className={cn(\n        \"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </motion.span>\n  );\n}\n"},{"path":"lib/hooks/use-dismiss.ts","type":"util","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | SVGElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/hooks/use-hover-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/hooks/use-tap-gesture.ts","type":"util","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/touch.ts","type":"util","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"components/previews/blocks/file-upload.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { RotateCcw } from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n  FileUpload,\n  type FileUploadItem,\n  type FileUploadVariant,\n} from \"@/components/motion/file-upload\";\n\nconst initialItems: FileUploadItem[] = [\n  {\n    id: \"brand-assets\",\n    name: \"brand-assets.zip\",\n    size: 18_400_000,\n    type: \"application/zip\",\n    progress: 100,\n    status: \"success\",\n  },\n  {\n    id: \"release-video\",\n    name: \"release-cut.mov\",\n    size: 84_200_000,\n    type: \"video/quicktime\",\n    progress: 58,\n    status: \"uploading\",\n  },\n  {\n    id: \"contracts\",\n    name: \"vendor-contract.pdf\",\n    size: 2_800_000,\n    type: \"application/pdf\",\n    progress: 32,\n    status: \"error\",\n    error: \"Connection lost\",\n  },\n];\n\nconst variants: { id: FileUploadVariant; label: string }[] = [\n  { id: \"centered\", label: \"Centered\" },\n  { id: \"default\", label: \"Row\" },\n];\n\nexport function FileUploadPreview() {\n  const [items, setItems] = useState(initialItems);\n  const [variant, setVariant] = useState<FileUploadVariant>(\"centered\");\n  const timersRef = useRef<Map<string, ReturnType<typeof setInterval>>>(\n    new Map(),\n  );\n\n  const stopUpload = useCallback((id: string) => {\n    const timer = timersRef.current.get(id);\n    if (!timer) return;\n    clearInterval(timer);\n    timersRef.current.delete(id);\n  }, []);\n\n  const startUpload = useCallback(\n    (id: string) => {\n      stopUpload(id);\n\n      const timer = setInterval(() => {\n        setItems((current) => {\n          const target = current.find((item) => item.id === id);\n          if (target?.status !== \"uploading\") {\n            stopUpload(id);\n            return current;\n          }\n\n          const nextProgress = Math.min(\n            100,\n            (target.progress ?? 0) + 7 + Math.random() * 12,\n          );\n\n          if (nextProgress >= 100) {\n            stopUpload(id);\n          }\n\n          return current.map((item) =>\n            item.id === id\n              ? {\n                  ...item,\n                  progress: nextProgress,\n                  status: nextProgress >= 100 ? \"success\" : \"uploading\",\n                }\n              : item,\n          );\n        });\n      }, 520);\n\n      timersRef.current.set(id, timer);\n    },\n    [stopUpload],\n  );\n\n  useEffect(() => {\n    startUpload(\"release-video\");\n\n    return () => {\n      for (const timer of timersRef.current.values()) {\n        clearInterval(timer);\n      }\n      timersRef.current.clear();\n    };\n  }, [startUpload]);\n\n  return (\n    <div className=\"flex min-h-[30rem] w-full items-center justify-center\">\n      <div className=\"w-full max-w-md rounded-[2rem] border border-border bg-background p-3\">\n        <div className=\"mb-3 flex flex-wrap items-center justify-between gap-2 px-1\">\n          <div>\n            <p className=\"text-sm font-semibold text-foreground\">\n              Upload package\n            </p>\n            <p className=\"text-xs text-muted-foreground\">\n              {items.filter((item) => item.status === \"success\").length} of{\" \"}\n              {items.length} files ready\n            </p>\n          </div>\n\n          <div className=\"flex items-center gap-1.5\">\n            <div className=\"flex rounded-full border border-border bg-muted p-1\">\n              {variants.map((entry) => {\n                const selected = entry.id === variant;\n\n                return (\n                  <button\n                    key={entry.id}\n                    type=\"button\"\n                    onClick={() => setVariant(entry.id)}\n                    data-selected={selected}\n                    className=\"h-7 rounded-full px-3 text-xs font-medium text-muted-foreground transition-[background-color,color,transform] duration-150 hover:text-foreground active:scale-95 data-[selected=true]:bg-background data-[selected=true]:text-foreground\"\n                  >\n                    {entry.label}\n                  </button>\n                );\n              })}\n            </div>\n\n            <button\n              type=\"button\"\n              onClick={() => {\n                for (const item of items) {\n                  stopUpload(item.id);\n                }\n                setItems(initialItems);\n                startUpload(\"release-video\");\n              }}\n              className=\"grid h-9 w-9 place-items-center rounded-full border border-border text-muted-foreground transition-colors hover:text-foreground active:scale-95\"\n              aria-label=\"Reset upload queue\"\n            >\n              <RotateCcw className=\"h-3.5 w-3.5\" />\n            </button>\n          </div>\n        </div>\n\n        <FileUpload\n          value={items}\n          variant={variant}\n          onValueChange={setItems}\n          onFilesAdded={(added) => {\n            for (const item of added) {\n              startUpload(item.id);\n            }\n          }}\n          onRetry={(item) => startUpload(item.id)}\n          onRemove={(item) => stopUpload(item.id)}\n          maxFiles={5}\n          title={variant === \"centered\" ? \"Drop files to upload\" : \"Drop release files\"}\n          description=\"PDF, images, video or zipped assets\"\n        />\n      </div>\n    </div>\n  );\n}\n"}]}