{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upload-list",
  "type": "registry:ui",
  "title": "Upload List",
  "description": "Per-file progress with smoothed speed and time left, retry, and a tick on finish.",
  "categories": [
    "forms"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/upload-list.tsx",
      "content": "\"use client\";\nimport { Progress } from \"@base-ui/react/progress\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/* ------------------------------------------------------------------ */\n/* Data                                                                */\n/* ------------------------------------------------------------------ */\n\nexport type UploadStatus = \"queued\" | \"uploading\" | \"processing\" | \"done\" | \"error\";\n\nexport type UploadItem = {\n  id: string;\n  name: string;\n  /** Bytes. */\n  size: number;\n  /** Bytes sent so far. */\n  loaded: number;\n  status: UploadStatus;\n  /** Smoothed bytes per second. useUploads measures it; bring your own if you manage state yourself. */\n  speed?: number;\n  /** Why it failed, in a sentence: \"Connection lost at 24 MB.\" */\n  error?: string;\n  /** An image URL for the tile. useUploads makes one for images and revokes it when the row goes. */\n  thumbnail?: string;\n};\n\n/** Sends one file. Report bytes with onProgress, call onProcessing once the bytes are up but the server is still working, and honor the signal. */\nexport type Uploader = (file: File, ctx: { signal: AbortSignal; onProgress: (loaded: number) => void; onProcessing: () => void }) => Promise<unknown>;\n\n/** 10485760 → \"10 MB\", with a no-break space so sizes never wrap mid-value. */\nexport function formatBytes(bytes: number) {\n  if (bytes < 1024) return `${Math.round(bytes)}\\u00a0B`;\n  const units = [\"KB\", \"MB\", \"GB\", \"TB\"];\n  let v = bytes / 1024;\n  let i = 0;\n  while (v >= 1024 && i < units.length - 1) {\n    v /= 1024;\n    i++;\n  }\n  return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)}\\u00a0${units[i]}`;\n}\n\n/** 5 → \"5s\", 80 → \"1m 20s\", 5400 → \"1h 30m\". */\nexport function formatEta(seconds: number) {\n  const s = Math.max(1, Math.ceil(seconds));\n  if (s < 60) return `${s}s`;\n  const m = Math.floor(s / 60);\n  if (s < 3600) return m >= 10 ? `${m}m` : `${m}m ${s % 60}s`;\n  return `${Math.floor(m / 60)}h ${m % 60}m`;\n}\n\n// Truncates in the middle at any width: the head ellipsizes, the tail (the last few\n// characters and the extension) never does, so \"onboarding-walk…through.mov\" keeps its type.\nfunction MiddleName({ name, className }: { name: string; className?: string }) {\n  const dot = name.lastIndexOf(\".\");\n  const cut = Math.max(0, name.length - Math.max(8, dot > 0 ? name.length - dot + 5 : 8));\n  const head = name.slice(0, cut);\n  const tail = name.slice(cut);\n  return (\n    <span className={cn(\"flex min-w-0\", className)} title={name}>\n      <span className=\"truncate\">{head}</span>\n      <span className=\"shrink-0 whitespace-pre\">{tail}</span>\n    </span>\n  );\n}\n\nconst plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;\nconst active = (s: UploadStatus) => s === \"queued\" || s === \"uploading\" || s === \"processing\";\n\n/* ------------------------------------------------------------------ */\n/* The queue: optional, for when you don't already have upload state   */\n/* ------------------------------------------------------------------ */\n\ntype Meter = { samples: { t: number; b: number }[]; speed?: number };\n\n// Speed over a sliding 3s window, then eased, so \"time left\" settles instead of jittering every tick.\nfunction measure(m: Meter, loaded: number) {\n  const t = performance.now();\n  m.samples.push({ t, b: loaded });\n  while (m.samples.length > 2 && t - m.samples[0].t > 3000) m.samples.shift();\n  const first = m.samples[0];\n  const dt = (t - first.t) / 1000;\n  if (dt < 0.6) return m.speed;\n  const now = (loaded - first.b) / dt;\n  m.speed = m.speed == null ? now : m.speed * 0.6 + now * 0.4;\n  return m.speed;\n}\n\nfunction createStore(onChange: (items: UploadItem[]) => void) {\n  let list: UploadItem[] = [];\n  let cfg: { upload?: Uploader; concurrency: number } = { concurrency: 3 };\n  let frame = 0;\n  let seq = 0;\n  const files = new Map<string, File>();\n  const ctrls = new Map<string, AbortController>();\n  const meters = new Map<string, Meter>();\n  const urls = new Map<string, string>();\n\n  const emit = () => {\n    cancelAnimationFrame(frame);\n    frame = 0;\n    onChange(list);\n  };\n  // Progress can fire hundreds of times a second; the list re-renders at most once a frame.\n  const soon = () => {\n    if (!frame) frame = requestAnimationFrame(emit);\n  };\n  const patch = (id: string, p: Partial<UploadItem>) => {\n    list = list.map((i) => (i.id === id ? { ...i, ...p } : i));\n  };\n  const forget = (id: string) => {\n    ctrls.get(id)?.abort();\n    ctrls.delete(id);\n    meters.delete(id);\n    files.delete(id);\n    const url = urls.get(id);\n    if (url) URL.revokeObjectURL(url);\n    urls.delete(id);\n  };\n\n  const start = (id: string) => {\n    const file = files.get(id);\n    const upload = cfg.upload;\n    if (!file || !upload) return;\n    const c = new AbortController();\n    const m: Meter = { samples: [] };\n    ctrls.set(id, c);\n    meters.set(id, m);\n    patch(id, { status: \"uploading\", loaded: 0, speed: undefined, error: undefined });\n    upload(file, {\n      signal: c.signal,\n      onProgress: (loaded) => {\n        if (c.signal.aborted) return;\n        patch(id, { loaded: Math.min(loaded, file.size), speed: measure(m, loaded) });\n        soon();\n      },\n      onProcessing: () => {\n        if (c.signal.aborted) return;\n        patch(id, { status: \"processing\", loaded: file.size, speed: undefined });\n        soon();\n      },\n    }).then(\n      () => {\n        if (c.signal.aborted) return;\n        ctrls.delete(id);\n        patch(id, { status: \"done\", loaded: file.size, speed: undefined });\n        pump();\n        emit();\n      },\n      (err: unknown) => {\n        if (c.signal.aborted) return;\n        ctrls.delete(id);\n        const msg = err instanceof Error && err.message ? err.message : \"Couldn’t upload. Try again.\";\n        patch(id, { status: \"error\", error: msg, speed: undefined });\n        pump();\n        emit();\n      },\n    );\n  };\n\n  const pump = () => {\n    let slots = cfg.concurrency - list.filter((i) => i.status === \"uploading\" || i.status === \"processing\").length;\n    for (const i of list) {\n      if (slots <= 0) break;\n      if (i.status !== \"queued\") continue;\n      slots--;\n      start(i.id);\n    }\n  };\n\n  return {\n    configure(next: { upload: Uploader; concurrency: number }) {\n      cfg = next;\n    },\n    dispose() {\n      for (const id of [...files.keys()]) forget(id);\n      cancelAnimationFrame(frame);\n    },\n    add(incoming: File[]) {\n      const added = incoming.map((file) => {\n        const id = `u${Date.now().toString(36)}${seq++}`;\n        files.set(id, file);\n        let thumbnail: string | undefined;\n        if (file.type.startsWith(\"image/\") && file.size < 25 * 1024 * 1024) {\n          thumbnail = URL.createObjectURL(file);\n          urls.set(id, thumbnail);\n        }\n        return { id, name: file.name, size: file.size, loaded: 0, status: \"queued\" as const, thumbnail };\n      });\n      list = [...list, ...added];\n      pump();\n      emit();\n      return added.map((a) => a.id);\n    },\n    cancel(id: string) {\n      forget(id);\n      list = list.filter((i) => i.id !== id);\n      pump();\n      emit();\n    },\n    remove(id: string) {\n      forget(id);\n      list = list.filter((i) => i.id !== id);\n      emit();\n    },\n    retry(id: string) {\n      patch(id, { status: \"queued\", loaded: 0, error: undefined });\n      pump();\n      emit();\n    },\n    retryAll() {\n      list = list.map((i) => (i.status === \"error\" ? { ...i, status: \"queued\", loaded: 0, error: undefined } : i));\n      pump();\n      emit();\n    },\n    cancelAll() {\n      for (const i of list) if (active(i.status)) forget(i.id);\n      list = list.filter((i) => !active(i.status));\n      emit();\n    },\n    clearCompleted() {\n      for (const i of list) if (i.status === \"done\") forget(i.id);\n      list = list.filter((i) => i.status !== \"done\");\n      emit();\n    },\n  };\n}\n\n/**\n * A small upload queue: runs `concurrency` uploads at a time, measures speed,\n * cancels with AbortController, retries, and cleans up image previews.\n * Spread the result straight into <UploadList>.\n */\nexport function useUploads({ upload, concurrency = 3 }: { upload: Uploader; concurrency?: number }) {\n  const [items, setItems] = useState<UploadItem[]>([]);\n  const [store] = useState(() => createStore(setItems));\n  useEffect(() => store.configure({ upload, concurrency }));\n  useEffect(() => () => store.dispose(), [store]);\n  return {\n    items,\n    add: store.add,\n    onCancel: store.cancel,\n    onRetry: store.retry,\n    onRemove: store.remove,\n    onCancelAll: store.cancelAll,\n    onRetryAll: store.retryAll,\n    onClearCompleted: store.clearCompleted,\n  };\n}\n\n/* ------------------------------------------------------------------ */\n/* The list                                                            */\n/* ------------------------------------------------------------------ */\n\nexport type UploadListProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  items: UploadItem[];\n  onCancel?: (id: string) => void;\n  onRetry?: (id: string) => void;\n  /** Shows a remove button on finished and failed rows. */\n  onRemove?: (id: string) => void;\n  onCancelAll?: (() => void) | undefined;\n  onRetryAll?: (() => void) | undefined;\n  onClearCompleted?: (() => void) | undefined;\n  /** The header with the overall count, bytes and time left. Defaults to on when there's more than one file. */\n  summary?: boolean;\n  /** Rendered in place of the rows when there are none. Without it the list collapses to nothing. */\n  empty?: React.ReactNode;\n  /** Accessible name of the list. */\n  label?: string;\n};\n\nexport function UploadList({\n  items,\n  onCancel,\n  onRetry,\n  onRemove,\n  onCancelAll,\n  onRetryAll,\n  onClearCompleted,\n  summary,\n  empty,\n  label = \"Uploads\",\n  className,\n  ...rest\n}: UploadListProps) {\n  const reduce = useReducedMotion();\n\n  // Announce transitions, not ticks: compare with the previous render's items while rendering.\n  const [prev, setPrev] = useState(items);\n  const [said, setSaid] = useState({ text: \"\", n: 0 });\n  if (prev !== items) {\n    const was = new Map(prev.map((i) => [i.id, i.status]));\n    const lines: string[] = [];\n    for (const i of items) {\n      const before = was.get(i.id);\n      if (before === i.status) continue;\n      if (i.status === \"done\") lines.push(`${i.name} uploaded.`);\n      else if (i.status === \"error\") lines.push(`${i.name} failed. ${i.error ?? \"\"}`.trim());\n    }\n    if (lines.length && items.length > 1 && items.every((i) => i.status === \"done\")) lines.push(\"All uploads complete.\");\n    setPrev(items);\n    if (lines.length) setSaid((s) => ({ text: lines.join(\" \"), n: s.n + 1 }));\n  }\n\n  const showSummary = summary ?? items.length > 1;\n\n  return (\n    <div\n      data-slot=\"upload-list\"\n      data-empty={items.length === 0 || undefined}\n      className={cn(\n        \"w-full min-w-0 overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-[var(--shadow)]\",\n        items.length === 0 && !empty && \"invisible border-transparent shadow-none\",\n        className,\n      )}\n      {...rest}\n    >\n      <AnimatePresence initial={false}>\n        {showSummary && items.length > 0 && (\n          <motion.div\n            key=\"summary\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.16, ease: ease.in } }}\n            transition={{ duration: 0.24, ease: ease.out }}\n          >\n            <Summary items={items} onCancelAll={onCancelAll} onRetryAll={onRetryAll} onClearCompleted={onClearCompleted} />\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {items.length === 0 && empty}\n\n      <ul aria-label={label} className=\"flex flex-col\">\n        <AnimatePresence initial={false}>\n          {items.map((item, index) => (\n            <motion.li\n              key={item.id}\n              layout={reduce ? false : \"position\"}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, height: \"auto\", filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, height: 0, filter: \"blur(2px)\", transition: { duration: 0.18, ease: ease.in } }}\n              transition={{ duration: 0.26, ease: ease.out, layout: spring.soft }}\n              className=\"overflow-hidden\"\n            >\n              <Row item={item} first={index === 0 && !showSummary} onCancel={onCancel} onRetry={onRetry} onRemove={onRemove} reduce={!!reduce} />\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </ul>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {said.text}\n        {said.n % 2 ? \"\\u00a0\" : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction Summary({ items, onCancelAll, onRetryAll, onClearCompleted }: Pick<UploadListProps, \"items\" | \"onCancelAll\" | \"onRetryAll\" | \"onClearCompleted\">) {\n  const running = items.filter((i) => active(i.status));\n  const failed = items.filter((i) => i.status === \"error\").length;\n  const done = items.filter((i) => i.status === \"done\").length;\n  const total = items.reduce((a, i) => a + i.size, 0);\n  const loaded = items.reduce((a, i) => a + (i.status === \"done\" ? i.size : i.status === \"error\" ? 0 : i.loaded), 0);\n  const counted = items.reduce((a, i) => a + (i.status === \"error\" ? 0 : i.size), 0);\n  const speed = running.reduce((a, i) => a + (i.speed ?? 0), 0);\n  const left = running.reduce((a, i) => a + (i.size - i.loaded), 0);\n  const eta = speed > 0 && running.some((i) => i.status === \"uploading\") ? formatEta(left / speed) : null;\n\n  const processingOnly = running.length > 0 && running.every((i) => i.status === \"processing\");\n  const title = processingOnly\n    ? `Processing ${plural(running.length, \"file\")}`\n    : running.length\n      ? `Uploading ${plural(running.length, \"file\")}`\n      : failed\n        ? `${plural(failed, \"upload\")} failed`\n        : `${plural(done, \"file\")} uploaded`;\n  const meta = processingOnly\n    ? \"Almost done\"\n    : running.length\n      ? // Time left leads: it's the number people look for, and it survives truncation on a phone.\n        [eta && `${eta} left`, `${formatBytes(loaded)} of ${formatBytes(counted)}`].filter(Boolean).join(\" · \")\n      : failed\n        ? `${done} of ${items.length} uploaded`\n        : formatBytes(total);\n  const action = running.length\n    ? onCancelAll && { label: \"Cancel all\", run: onCancelAll }\n    : failed\n      ? onRetryAll && { label: \"Retry all\", run: onRetryAll }\n      : onClearCompleted && { label: \"Clear\", run: onClearCompleted };\n\n  const whole = counted ? loaded / counted : 0;\n  return (\n    <div className=\"relative flex h-11 items-center gap-3 pl-3.5 pr-2\">\n      <div className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n        <span className={cn(\"shrink-0 text-[13px] font-medium tracking-[-0.01em]\", !running.length && failed && \"text-danger\")}>{title}</span>\n        <span className=\"min-w-0 truncate text-[12px] text-fg-3 tabular\">{meta}</span>\n      </div>\n      {action && (\n        <button\n          type=\"button\"\n          onClick={action.run}\n          className={cn(\n            \"relative inline-flex h-7 shrink-0 items-center rounded-md px-2 text-[12px] font-medium text-fg-2\",\n            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n            \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97]\",\n            \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n          )}\n        >\n          {action.label}\n        </button>\n      )}\n      {/* The header's bottom hairline doubles as the overall progress. */}\n      <span aria-hidden className=\"absolute inset-x-0 bottom-0 h-px bg-line\" />\n      <span\n        aria-hidden\n        className={cn(\"absolute inset-x-0 bottom-0 h-px origin-left bg-fg-3 transition-[scale,opacity] duration-300 ease-out-expo\", !running.length && \"opacity-0 duration-500\")}\n        style={{ scale: `${whole} 1` }}\n      />\n    </div>\n  );\n}\n\nconst iconButton = cn(\n  \"relative grid size-7 shrink-0 place-items-center rounded-md text-fg-3\",\n  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n  \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.9]\",\n  // Draws at 28px; on touch the press target grows to 44px.\n  \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n);\n\nconst glyph = {\n  width: 14,\n  height: 14,\n  viewBox: \"0 0 16 16\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.4,\n  strokeLinecap: \"round\" as const,\n  strokeLinejoin: \"round\" as const,\n  \"aria-hidden\": true,\n};\n\nfunction Row({\n  item,\n  first,\n  onCancel,\n  onRetry,\n  onRemove,\n  reduce,\n}: {\n  item: UploadItem;\n  first: boolean;\n  onCancel?: (id: string) => void;\n  onRetry?: (id: string) => void;\n  onRemove?: (id: string) => void;\n  reduce: boolean;\n}) {\n  const { status, name, size, loaded, speed } = item;\n  const pct = size ? Math.min(100, (loaded / size) * 100) : 0;\n  const showBar = active(status);\n  const ext = (name.includes(\".\") ? (name.split(\".\").pop() ?? \"\") : \"\").slice(0, 4).toUpperCase();\n  const [retries, setRetries] = useState(0);\n\n  const eta = status === \"uploading\" && speed && speed > 0 ? formatEta((size - loaded) / speed) : null;\n  const meta =\n    status === \"queued\"\n      ? `Waiting · ${formatBytes(size)}`\n      : status === \"uploading\"\n        ? [`${formatBytes(loaded)} of ${formatBytes(size)}`, speed ? `${formatBytes(speed)}/s` : \"Starting…\", eta && `${eta} left`].filter(Boolean).join(\" · \")\n        : status === \"processing\"\n          ? `Processing · ${formatBytes(size)}`\n          : status === \"done\"\n            ? `Uploaded · ${formatBytes(size)}`\n            : (item.error ?? \"Couldn’t upload. Try again.\");\n\n  return (\n    <div data-status={status} className={cn(\"group/row flex items-center gap-3 py-2.5 pl-3 pr-2\", !first && \"border-t border-line\")}>\n      <div className=\"relative size-9 shrink-0\">\n        <div\n          className={cn(\n            \"grid size-full place-items-center overflow-hidden rounded-lg border border-line-2 bg-frame font-mono text-[9.5px] font-medium tracking-[0.02em] text-fg-3\",\n            \"transition-[border-color,color] duration-200 group-data-[status=error]/row:border-danger/40 group-data-[status=error]/row:text-danger\",\n          )}\n        >\n          {item.thumbnail ? (\n            // eslint-disable-next-line @next/next/no-img-element -- blob URLs of local files; next/image can't optimize them.\n            <img src={item.thumbnail} alt=\"\" width={36} height={36} className=\"size-full object-cover\" />\n          ) : (\n            ext || <FileGlyph />\n          )}\n        </div>\n        {/* The badge pops once the bar has filled and folded away. */}\n        <AnimatePresence initial={false}>\n          {(status === \"done\" || status === \"error\") && (\n            <motion.span\n              key={status}\n              aria-hidden\n              className={cn(\n                \"absolute -bottom-1 -right-1 grid size-4 place-items-center rounded-full ring-2 ring-raised\",\n                status === \"done\" ? \"bg-success text-raised\" : \"bg-danger text-raised\",\n              )}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n              animate={{ opacity: 1, scale: 1, transition: reduce ? { duration: 0.15 } : { ...spring.pop, delay: status === \"done\" ? 0.32 : 0 } }}\n              exit={{ opacity: 0, scale: reduce ? 1 : 0.6, transition: { duration: 0.1 } }}\n            >\n              {status === \"done\" ? (\n                <svg width=\"10\" height=\"10\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                  <motion.path\n                    d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                    initial={reduce ? false : { pathLength: 0 }}\n                    animate={{ pathLength: 1 }}\n                    transition={{ duration: 0.3, ease: ease.out, delay: 0.42 }}\n                  />\n                </svg>\n              ) : (\n                <svg width=\"10\" height=\"10\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\">\n                  <path d=\"M8 4.5v4.25M8 11.6v.01\" />\n                </svg>\n              )}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <MiddleName name={name} className=\"text-[13px] leading-[18px] tracking-[-0.005em] text-fg\" />\n        <div className=\"grid\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={status === \"error\" ? `error-${item.error}` : status}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 3 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.2, ease: ease.out }}\n              // Errors wrap: the reason is what someone needs to act on, so it is never cut off.\n              className={cn(\"col-start-1 row-start-1 text-[12px] leading-[18px] tabular\", status === \"error\" ? \"text-danger text-pretty\" : \"truncate text-fg-3\")}\n            >\n              {meta}\n            </motion.span>\n          </AnimatePresence>\n        </div>\n        {/* The bar folds away once it has filled; a failure folds it after showing where it stopped. */}\n        <div\n          className={cn(\n            \"grid transition-[grid-template-rows,opacity] duration-[240ms] ease-in-out-quart\",\n            showBar ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\",\n            status === \"done\" && \"delay-300\",\n            status === \"error\" && \"delay-500\",\n          )}\n        >\n          <div className=\"min-h-0 overflow-hidden\">\n            <Progress.Root\n              value={status === \"processing\" ? null : Math.round(pct)}\n              aria-label={`Uploading ${name}`}\n              getAriaValueText={(formatted) => (status === \"processing\" ? \"Processing\" : [formatted, eta && `${eta} left`].filter(Boolean).join(\", \"))}\n              className=\"pt-1.5 pb-0.5\"\n            >\n              <Progress.Track className=\"relative h-1 overflow-hidden rounded-full bg-fg/10\">\n                {status === \"processing\" ? (\n                  <span className=\"absolute inset-0 animate-indeterminate rounded-full bg-fg-3 motion-reduce:animate-pulse-soft\" />\n                ) : (\n                  <span\n                    className={cn(\n                      \"absolute inset-0 origin-left rounded-full bg-fg transition-[scale,background-color] duration-300 ease-out\",\n                      status === \"queued\" && \"bg-fg-4\",\n                      status === \"error\" && \"bg-danger\",\n                    )}\n                    style={{ scale: `${status === \"done\" ? 1 : pct / 100} 1` }}\n                  />\n                )}\n              </Progress.Track>\n            </Progress.Root>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex shrink-0 items-center gap-0.5\">\n        {status === \"error\" && onRetry && (\n          <button\n            type=\"button\"\n            aria-label={`Retry ${name}`}\n            onClick={() => {\n              setRetries((n) => n + 1);\n              onRetry(item.id);\n            }}\n            className={iconButton}\n          >\n            {/* One counter-clockwise turn per press: the retry visibly starts over. */}\n            <motion.svg {...glyph} animate={{ rotate: reduce ? 0 : retries * -360 }} transition={{ duration: 0.5, ease: ease.out }}>\n              <path d=\"M3 8a5 5 0 1 0 1.5-3.55M3 2.75V5h2.25\" />\n            </motion.svg>\n          </button>\n        )}\n        {active(status) && onCancel && (\n          <button type=\"button\" aria-label={`Cancel upload of ${name}`} onClick={() => onCancel(item.id)} className={iconButton}>\n            <svg {...glyph}>\n              <path d=\"m4.5 4.5 7 7M11.5 4.5l-7 7\" />\n            </svg>\n          </button>\n        )}\n        {(status === \"done\" || status === \"error\") && onRemove && (\n          <button type=\"button\" aria-label={`Remove ${name}`} onClick={() => onRemove(item.id)} className={cn(iconButton, \"text-fg-4\")}>\n            <svg {...glyph}>\n              <path d=\"m4.5 4.5 7 7M11.5 4.5l-7 7\" />\n            </svg>\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction FileGlyph() {\n  return (\n    <svg {...glyph}>\n      <path d=\"M4 2.5h5l3.5 3.5v7.5H4zM9 2.5V6h3.5\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/upload-list"
}
