{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-drop-zone-membrane",
  "title": "Drop Zone Membrane",
  "description": "A responsive membrane drop zone with shadcn attachments, upload progress, processing, retry and cancellation.",
  "dependencies": [
    "lucide-react",
    "@base-ui/react@^1.9.0",
    "class-variance-authority@^0.7.1",
    "cn@^0.4.0",
    "motion@14.2.0"
  ],
  "files": [
    {
      "path": "registry/components/drop-zone-membrane.tsx",
      "content": "\"use client\";\n\n/* ─────────────────────────────────────────────────────────\n * DROP ZONE: the border reaches for the file\n *\n *   armed     drag files anywhere over the page and the line\n *             breathes out a pixel and takes the accent\n *   reach     bring them near and the nearest stretch of line\n *             bends out to meet them; the arrow turns to watch.\n *             Close to the edge the bulge reaches past the\n *             file, so it is held before it crosses\n *   over      inside, the line lets go with a wobble, the zone\n *             swells, and the label says what you're holding\n *   drop      the border gulps and rings out from the drop\n *             point; files appear directly as attachment cards\n *   upload    Attachment shows ready, uploading,\n *             processing, failed and complete states\n *   refuse    a type the zone can't take makes the line recoil\n *             and turns it red; a refused drop is pushed back\n *             out where it landed, and the label says why\n *\n * The zone is a real button: Enter or Space browses, paste\n * works while it has focus, and every result is announced.\n * Your own drags (a file from a sidebar, say) drive it too,\n * through the handle: drag, cancel, drop.\n * ───────────────────────────────────────────────────────── */\n\nimport * as React from \"react\";\nimport { ArrowUp, X } from \"lucide-react\";\nimport {\n  MembraneAttachment,\n  formatBytes,\n  type MembraneAttachmentStatus,\n} from \"./membrane-attachment\";\nexport { MembraneAttachment, formatBytes } from \"./membrane-attachment\";\nimport { TextMorph } from \"./text-morph\";\nimport {\n  EASE_OUT,\n  INTERACTION_FOCUS,\n  interactionIcon,\n  useInteractionReducedMotion,\n} from \"./ui-interaction-motion\";\nimport { useMembrane } from \"./use-membrane\";\n\nexport type DropZoneMembraneStatus = MembraneAttachmentStatus;\n\nexport interface DropZoneMembraneFile {\n  id: string;\n  file: File;\n  status: DropZoneMembraneStatus;\n  /** 0 to 1. */\n  progress: number;\n  error?: string;\n}\n\nexport type DropZoneMembraneUpload = (\n  file: File,\n  context: {\n    progress: (fraction: number) => void;\n    processing: () => void;\n    signal: AbortSignal;\n  },\n) => void | Promise<void>;\n\nexport interface DropZoneMembraneHandle {\n  /** Something your app is dragging (not a file from the system) is at these client coordinates. Returns whether a drop there would land. */\n  drag: (clientX: number, clientY: number, types?: string[]) => boolean;\n  /** Your drag ended without a drop. */\n  cancel: () => void;\n  /** Drop files as if from the system, at client coordinates or the zone's centre. */\n  drop: (files: File[], at?: { x: number; y: number }) => void;\n  /** Open the file picker. */\n  browse: () => void;\n}\n\nexport interface DropZoneMembraneProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onDrop\" | \"title\"\n> {\n  /** Uploads one file: report progress, honour the signal, resolve when done or throw to show an error with Retry. */\n  onUpload?: DropZoneMembraneUpload;\n  /** Start uploads on selection. False exposes an Upload action on each ready file. */\n  autoUpload?: boolean;\n  /** The files currently in the list, after every add and remove. */\n  onFilesChange?: (files: File[]) => void;\n  /** Like an input's accept: MIME types, wildcards and extensions, e.g. \"image/*,.pdf\". */\n  accept?: string;\n  /** Largest file in bytes. */\n  maxSize?: number;\n  maxFiles?: number;\n  /** One file at a time: a new drop replaces it. */\n  multiple?: boolean;\n  /** The resting label. */\n  title?: string;\n  /** File requirements in the tray footer, e.g. \"PNG, JPG or PDF, up to 10 MB\". */\n  hint?: string;\n  disabled?: boolean;\n  ref?: React.Ref<DropZoneMembraneHandle>;\n}\n\ntype Row = DropZoneMembraneFile & { closing: boolean };\ntype Drag = { count: number; ok: boolean; held: boolean };\n\nconst RADIUS = 14;\nconst HOLD = 16; // a file this close outside the zone is already held by the bulge, and drops into it\n\nconst matchRule = (rule: string, type: string, name: string) =>\n  rule.startsWith(\".\")\n    ? name.endsWith(rule)\n    : rule.endsWith(\"/*\")\n      ? type.startsWith(rule.slice(0, -1))\n      : type === rule;\nconst rulesOf = (accept?: string) =>\n  (accept ?? \"\")\n    .split(\",\")\n    .map((r) => r.trim().toLowerCase())\n    .filter(Boolean);\nconst EXTENSIONS: Record<string, string> = {\n  \".pdf\": \"application/pdf\",\n  \".png\": \"image/png\",\n  \".jpg\": \"image/jpeg\",\n  \".jpeg\": \"image/jpeg\",\n  \".gif\": \"image/gif\",\n  \".webp\": \"image/webp\",\n  \".avif\": \"image/avif\",\n  \".svg\": \"image/svg+xml\",\n  \".txt\": \"text/plain\",\n  \".csv\": \"text/csv\",\n  \".md\": \"text/markdown\",\n  \".json\": \"application/json\",\n  \".zip\": \"application/zip\",\n  \".mp4\": \"video/mp4\",\n  \".mov\": \"video/quicktime\",\n  \".webm\": \"video/webm\",\n  \".mp3\": \"audio/mpeg\",\n  \".wav\": \"audio/wav\",\n};\n/** During a drag only the type is known, and sometimes not even that: refuse only what surely can't match. */\nconst mightAccept = (rules: string[], type: string) => {\n  if (!rules.length || !type) return true;\n  const t = type.toLowerCase();\n  return rules.some((r) =>\n    r.startsWith(\".\")\n      ? !EXTENSIONS[r] || EXTENSIONS[r] === t\n      : matchRule(r, t, \"\"),\n  );\n};\nconst accepts = (rules: string[], file: File) =>\n  !rules.length ||\n  rules.some((r) =>\n    matchRule(\n      r,\n      file.type.toLowerCase() ||\n        EXTENSIONS[file.name.toLowerCase().slice(file.name.lastIndexOf(\".\"))] ||\n        \"\",\n      file.name.toLowerCase(),\n    ),\n  );\n\nconst onlyLabel = (hint?: string) =>\n  hint\n    ? `Only ${/^.[a-z]/.test(hint) ? hint[0].toLowerCase() + hint.slice(1) : hint}`\n    : undefined;\nlet counter = 0;\n\nexport function DropZoneMembrane({\n  onUpload,\n  autoUpload = true,\n  onFilesChange,\n  accept = \"image/*,.pdf\",\n  maxSize = 10_000_000,\n  maxFiles = 2,\n  multiple = true,\n  title = \"Drop files here\",\n  hint = \"images or PDFs, up to 10 MB\",\n  disabled = false,\n  ref,\n  className = \"\",\n  ...props\n}: DropZoneMembraneProps) {\n  const reduced = useInteractionReducedMotion();\n  const hintId = React.useId();\n  const membrane = useMembrane({ radius: RADIUS, reduced });\n  const rules = React.useMemo(() => rulesOf(accept), [accept]);\n  const root = React.useRef<HTMLDivElement>(null);\n  const zone = React.useRef<HTMLButtonElement | null>(null);\n  const input = React.useRef<HTMLInputElement>(null);\n  const [drag, setDrag] = React.useState<Drag | null>(null);\n  const [notice, setNotice] = React.useState<{\n    text: string;\n    error: boolean;\n  } | null>(null);\n  const [rows, setRows] = React.useState<Row[]>([]);\n  const [said, setSaid] = React.useState(\"\");\n  const rowsRef = React.useRef(rows);\n  const updateRows = React.useCallback((update: (rows: Row[]) => Row[]) => {\n    const next = update(rowsRef.current);\n    rowsRef.current = next;\n    setRows(next);\n  }, []);\n  const controllers = React.useRef(new Map<string, AbortController>());\n  const timers = React.useRef(new Set<ReturnType<typeof setTimeout>>());\n  const noticeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const upload = React.useRef(onUpload);\n  upload.current = onUpload;\n\n  const later = React.useCallback((fn: () => void, ms: number) => {\n    const t = setTimeout(() => {\n      timers.current.delete(t);\n      fn();\n    }, ms);\n    timers.current.add(t);\n  }, []);\n  React.useEffect(\n    () => () => {\n      timers.current.forEach(clearTimeout);\n      if (noticeTimer.current) clearTimeout(noticeTimer.current);\n      controllers.current.forEach((c) => c.abort());\n    },\n    [],\n  );\n\n  const tell = React.useCallback((text: string, error: boolean) => {\n    if (noticeTimer.current) clearTimeout(noticeTimer.current);\n    setNotice({ text, error });\n    noticeTimer.current = setTimeout(\n      () => setNotice(null),\n      error ? 2800 : 1800,\n    );\n  }, []);\n\n  const local = (clientX: number, clientY: number) => {\n    const r = zone.current?.getBoundingClientRect();\n    return r\n      ? { x: clientX - r.left, y: clientY - r.top, w: r.width, h: r.height }\n      : { x: 0, y: 0, w: 0, h: 0 };\n  };\n\n  // Follow a drag: bend the line, decide whether the file is held, and update the label only when that changes.\n  const track = React.useCallback(\n    (clientX: number, clientY: number, types: string[]) => {\n      if (disabled) return false;\n      const p = local(clientX, clientY);\n      const outside = Math.hypot(\n        Math.max(-p.x, 0, p.x - p.w),\n        Math.max(-p.y, 0, p.y - p.h),\n      );\n      const ok = !types.length || types.some((t) => mightAccept(rules, t));\n      const isHeld = outside < HOLD;\n      membrane.set(\"pointer\", {\n        x: p.x,\n        y: p.y,\n        kind: ok ? \"reach\" : \"recoil\",\n      });\n      membrane.swell(isHeld ? (ok ? 3 : -2) : 1.5);\n      setDrag((d) =>\n        d && d.count === types.length && d.ok === ok && d.held === isHeld\n          ? d\n          : { count: types.length, ok, held: isHeld },\n      );\n      return isHeld;\n    },\n    [disabled, rules, membrane],\n  );\n\n  const release = React.useCallback(() => {\n    membrane.set(\"pointer\", null);\n    membrane.swell(0);\n    setDrag(null);\n  }, [membrane]);\n\n  const start = React.useCallback(\n    (id: string, file: File) => {\n      if (!rowsRef.current.some((r) => r.id === id && !r.closing)) return;\n      controllers.current.get(id)?.abort();\n      const run = upload.current;\n      const patch = (next: Partial<Row>) =>\n        updateRows((list) =>\n          list.map((r) => (r.id === id ? { ...r, ...next } : r)),\n        );\n      if (!run) return patch({ status: \"idle\", progress: 0 });\n      const controller = new AbortController();\n      controllers.current.set(id, controller);\n      const active = () =>\n        !controller.signal.aborted &&\n        controllers.current.get(id) === controller;\n      patch({ status: \"uploading\", progress: 0, error: undefined });\n      let last = 0;\n      const progress = (f: number) => {\n        const v = Math.max(last, Math.min(1, Number.isFinite(f) ? f : 0));\n        if (!active() || Math.abs(v - last) < 0.005) return;\n        last = v;\n        patch({ progress: v });\n      };\n      Promise.resolve()\n        .then(() => {\n          if (controller.signal.aborted) return;\n          return run(file, {\n            progress,\n            processing: () => {\n              if (active()) patch({ status: \"processing\", progress: 1 });\n            },\n            signal: controller.signal,\n          });\n        })\n        .then(\n          () => {\n            if (controller.signal.aborted) return;\n            patch({ status: \"done\", progress: 1 });\n            setSaid(`${file.name} uploaded`);\n          },\n          (error: unknown) => {\n            if (controller.signal.aborted) return;\n            const message =\n              error instanceof Error && error.message\n                ? error.message\n                : \"Upload failed\";\n            patch({ status: \"error\", error: message });\n            setSaid(`${file.name} failed to upload. ${message}`);\n          },\n        )\n        .finally(() => {\n          if (controllers.current.get(id) === controller)\n            controllers.current.delete(id);\n        });\n    },\n    [updateRows],\n  );\n\n  const remove = React.useCallback(\n    (id: string) => {\n      const item = rowsRef.current.find((r) => r.id === id);\n      const focused = root.current\n        ?.querySelector('[data-file=\"' + id + '\"]')\n        ?.contains(document.activeElement);\n      if (focused) zone.current?.focus({ preventScroll: true });\n      controllers.current.get(id)?.abort();\n      controllers.current.delete(id);\n      if (item) setSaid(item.file.name + \" removed\");\n      updateRows((list) =>\n        list.map((r) => (r.id === id ? { ...r, closing: true } : r)),\n      );\n      later(\n        () => updateRows((list) => list.filter((r) => r.id !== id)),\n        reduced ? 0 : 420,\n      );\n    },\n    [later, reduced, updateRows],\n  );\n\n  // React to the drop point, then insert the actual attachment cards.\n  const take = React.useCallback(\n    (files: File[], at: { x: number; y: number }) => {\n      if (disabled || !files.length) return;\n      const current = rowsRef.current.filter((r) => !r.closing);\n      const room = multiple ? (maxFiles ?? Infinity) - current.length : 1;\n      const accepted: File[] = [];\n      const refused: string[] = [];\n      for (const file of multiple ? files : files.slice(0, 1)) {\n        if (!accepts(rules, file)) refused.push(`${file.name} isn't accepted`);\n        else if (maxSize !== undefined && file.size > maxSize)\n          refused.push(`${file.name} is over ${formatBytes(maxSize)}`);\n        else if (accepted.length >= room)\n          refused.push(`Up to ${maxFiles} files`);\n        else if (\n          [...current.map((r) => r.file), ...accepted].some(\n            (f) =>\n              f.name === file.name &&\n              f.size === file.size &&\n              f.lastModified === file.lastModified &&\n              f.type === file.type,\n          )\n        )\n          refused.push(file.name + \" is already added\");\n        else accepted.push(file);\n      }\n      if (!multiple && files.length > 1) refused.push(\"One file at a time\");\n      if (!accepted.length) {\n        membrane.spit(at.x, at.y);\n        tell(refused[0] ?? \"Nothing to add\", true);\n        setSaid(refused.join(\". \"));\n        return;\n      }\n      membrane.gulp(at.x, at.y);\n      if (!multiple) current.forEach((r) => remove(r.id));\n      const added: Row[] = accepted.map((file) => ({\n        id: `f${++counter}`,\n        file,\n        status: \"idle\",\n        progress: 0,\n        closing: false,\n      }));\n      updateRows((list) => [...list, ...added]);\n      if (autoUpload && upload.current)\n        added.forEach((r) => start(r.id, r.file));\n      const summary =\n        accepted.length === 1\n          ? `Added ${accepted[0].name}`\n          : `Added ${accepted.length} files`;\n      tell(\n        refused.length\n          ? refused.length === 1\n            ? refused[0]\n            : `${refused.length} files skipped`\n          : accepted.length === 1\n            ? \"File added\"\n            : summary,\n        refused.length > 0,\n      );\n      setSaid([summary, ...refused].join(\". \"));\n    },\n    [\n      disabled,\n      multiple,\n      maxFiles,\n      maxSize,\n      rules,\n      membrane,\n      tell,\n      remove,\n      start,\n      autoUpload,\n      updateRows,\n    ],\n  );\n\n  const centre = () => {\n    const el = zone.current;\n    return { x: (el?.offsetWidth ?? 0) / 2, y: (el?.offsetHeight ?? 0) / 2 };\n  };\n\n  // Files dragged in from the system: watch the whole window so the line can reach out before the file arrives.\n  React.useEffect(() => {\n    if (disabled) {\n      release();\n      return;\n    }\n    let quiet: ReturnType<typeof setTimeout> | null = null;\n    const end = () => {\n      if (quiet) clearTimeout(quiet);\n      release();\n    };\n    const over = (e: DragEvent) => {\n      const data = e.dataTransfer;\n      if (!data || !Array.from(data.types).includes(\"Files\")) return;\n      // dragover repeats while a drag is over the page; silence means it left.\n      if (quiet) clearTimeout(quiet);\n      quiet = setTimeout(end, 400);\n      const types = Array.from(data.items)\n        .filter((i) => i.kind === \"file\")\n        .map((i) => i.type);\n      if (track(e.clientX, e.clientY, types)) {\n        e.preventDefault();\n        data.dropEffect = \"copy\";\n      }\n    };\n    const leave = (e: DragEvent) => {\n      if (\n        !e.relatedTarget &&\n        (e.clientX <= 0 ||\n          e.clientY <= 0 ||\n          e.clientX >= window.innerWidth ||\n          e.clientY >= window.innerHeight)\n      )\n        end();\n    };\n    const drop = (e: DragEvent) => {\n      const files = Array.from(e.dataTransfer?.files ?? []);\n      const p = local(e.clientX, e.clientY);\n      const isHeld =\n        p.w > 0 &&\n        Math.hypot(Math.max(-p.x, 0, p.x - p.w), Math.max(-p.y, 0, p.y - p.h)) <\n          HOLD;\n      if (isHeld && files.length) {\n        e.preventDefault();\n        take(files, {\n          x: Math.max(0, Math.min(p.w, p.x)),\n          y: Math.max(0, Math.min(p.h, p.y)),\n        });\n      }\n      end();\n    };\n    window.addEventListener(\"dragenter\", over);\n    window.addEventListener(\"dragover\", over);\n    window.addEventListener(\"dragleave\", leave);\n    window.addEventListener(\"drop\", drop);\n    window.addEventListener(\"dragend\", end);\n    window.addEventListener(\"blur\", end);\n    return () => {\n      if (quiet) clearTimeout(quiet);\n      window.removeEventListener(\"dragenter\", over);\n      window.removeEventListener(\"dragover\", over);\n      window.removeEventListener(\"dragleave\", leave);\n      window.removeEventListener(\"drop\", drop);\n      window.removeEventListener(\"dragend\", end);\n      window.removeEventListener(\"blur\", end);\n    };\n  }, [disabled, track, release, take]);\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      drag: (x, y, types = []) => track(x, y, types),\n      cancel: release,\n      drop: (files, at) => {\n        const p = at ? local(at.x, at.y) : { ...centre(), w: 0, h: 0 };\n        release();\n        take(files, { x: p.x, y: p.y });\n      },\n      browse: () => {\n        if (!disabled) input.current?.click();\n      },\n    }),\n    [track, release, take, disabled],\n  );\n\n  const live = rows.filter((r) => !r.closing);\n  const liveKey = live.map((r) => r.id).join();\n  const report = React.useRef(onFilesChange);\n  report.current = onFilesChange;\n  const first = React.useRef(true);\n  React.useEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    report.current?.(\n      rowsRef.current.filter((r) => !r.closing).map((r) => r.file),\n    );\n  }, [liveKey]);\n\n  const refusing = drag ? !drag.ok : (notice?.error ?? false);\n  const label = drag\n    ? !drag.ok\n      ? (onlyLabel(hint) ?? \"This type can't be added\")\n      : drag.held\n        ? drag.count > 1\n          ? `Release to add ${drag.count}`\n          : \"Release to add\"\n        : drag.count > 1\n          ? `Drop ${drag.count} files here`\n          : \"Drop it here\"\n    : (notice?.text ?? title);\n  const icon: \"arrow\" | \"refuse\" | \"added\" = refusing\n    ? \"refuse\"\n    : notice && !notice.error\n      ? \"added\"\n      : \"arrow\";\n  const tone = refusing\n    ? \"refuse\"\n    : drag?.held\n      ? \"over\"\n      : drag\n        ? \"armed\"\n        : \"rest\";\n  const colors = {\n    rest: {\n      stroke: \"var(--border)\",\n      fill: \"color-mix(in oklab, var(--muted) 45%, transparent)\",\n    },\n    armed: {\n      stroke: \"color-mix(in oklab, var(--primary) 55%, var(--border))\",\n      fill: \"color-mix(in oklab, var(--primary) 3%, var(--background))\",\n    },\n    over: {\n      stroke: \"var(--primary)\",\n      fill: \"color-mix(in oklab, var(--primary) 7%, var(--background))\",\n    },\n    refuse: {\n      stroke: \"var(--destructive, #dc2626)\",\n      fill: \"color-mix(in oklab, var(--destructive, #dc2626) 5%, var(--background))\",\n    },\n  }[tone];\n\n  return (\n    <div\n      ref={root}\n      className={`@container relative w-full text-foreground ${className}`}\n      {...props}\n    >\n      <button\n        ref={(el) => {\n          zone.current = el;\n          membrane.attach(el);\n        }}\n        type=\"button\"\n        disabled={disabled}\n        aria-label=\"Browse files\"\n        data-slot=\"membrane-zone\"\n        aria-describedby={hint ? hintId : undefined}\n        onClick={() => input.current?.click()}\n        onPaste={(e) => {\n          const files = Array.from(e.clipboardData.files);\n          if (!files.length) return;\n          e.preventDefault();\n          take(files, centre());\n        }}\n        className={`group/zone relative grid w-full cursor-pointer grid-cols-[40px_minmax(0,1fr)] items-center gap-x-3 gap-y-3 rounded-[14px] p-4 text-left disabled:cursor-default disabled:opacity-55 @sm:grid-cols-[40px_minmax(0,1fr)_auto] ${INTERACTION_FOCUS}`}\n      >\n        <svg\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 size-full overflow-visible\"\n        >\n          <path\n            ref={membrane.path}\n            strokeWidth={1}\n            style={{\n              ...colors,\n              transition: reduced\n                ? \"none\"\n                : `stroke 280ms ${EASE_OUT}, fill 360ms ${EASE_OUT}`,\n            }}\n          />\n        </svg>\n        <span\n          aria-hidden=\"true\"\n          className=\"relative flex size-10 items-center justify-center rounded-xl border bg-background transition-colors duration-300\"\n          style={{\n            borderColor: tone === \"rest\" ? \"var(--border)\" : colors.stroke,\n            color:\n              tone === \"refuse\"\n                ? colors.stroke\n                : tone === \"rest\"\n                  ? \"var(--muted-foreground)\"\n                  : \"var(--accent-ink, var(--foreground))\",\n          }}\n        >\n          <span\n            ref={membrane.arrow}\n            className=\"absolute inset-0 flex items-center justify-center will-change-transform\"\n            style={interactionIcon(icon === \"arrow\", reduced)}\n          >\n            <ArrowUp className=\"size-4\" strokeWidth={1.75} />\n          </span>\n          <span\n            className=\"absolute inset-0 flex items-center justify-center\"\n            style={interactionIcon(icon === \"refuse\", reduced)}\n          >\n            <X className=\"size-4\" strokeWidth={1.75} />\n          </span>\n          <span\n            className=\"absolute inset-0 flex items-center justify-center\"\n            style={interactionIcon(icon === \"added\", reduced)}\n          >\n            <DrawnCheck on={icon === \"added\"} reduced={reduced} />\n          </span>\n        </span>\n        <span className=\"relative flex min-w-0 flex-col items-start gap-1\">\n          <span\n            title={label}\n            className={`max-w-full overflow-hidden text-[13.5px] font-medium tracking-[-0.01em] transition-colors duration-300 ${refusing ? \"text-destructive\" : \"\"}`}\n          >\n            <TextMorph className=\"max-w-full overflow-hidden align-bottom\">\n              {label}\n            </TextMorph>\n          </span>\n          <span className=\"text-[12px] leading-relaxed text-muted-foreground\">\n            Drag & drop, or paste a file\n          </span>\n        </span>\n        {/* One native button owns the whole tray, including this visual action. */}\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"membrane-browse\"\n          className=\"relative col-start-2 inline-flex min-h-11 w-fit items-center justify-center rounded-xl border border-border bg-background px-3.5 text-[12px] font-medium text-foreground transition-colors duration-200 group-hover/zone:bg-accent group-focus-visible/zone:bg-accent @sm:col-start-auto\"\n        >\n          Add files\n        </span>\n        <span\n          id={hintId}\n          data-slot=\"membrane-requirements\"\n          className=\"relative col-span-full flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 border-t border-border/70 pt-3 text-[11px] leading-relaxed text-muted-foreground\"\n        >\n          {hint && (\n            <span className=\"min-w-0 [overflow-wrap:anywhere]\">{hint}</span>\n          )}\n          {hint && <span aria-hidden=\"true\">·</span>}\n          <span>\n            {multiple ? `Up to ${maxFiles} files` : \"One file at a time\"}\n          </span>\n        </span>\n      </button>\n      <input\n        ref={input}\n        type=\"file\"\n        disabled={disabled}\n        hidden\n        multiple={multiple}\n        accept={accept}\n        onChange={(e) => {\n          const files = Array.from(e.currentTarget.files ?? []);\n          e.currentTarget.value = \"\";\n          take(files, centre());\n        }}\n      />\n\n      {rows.length > 0 && (\n        <ul aria-label=\"Files\" className=\"mt-3 list-none p-0\">\n          {rows.map((r) => (\n            <FileRow\n              key={r.id}\n              row={r}\n              reduced={reduced}\n              disabled={disabled}\n              canUpload={!!onUpload}\n              onRemove={() => remove(r.id)}\n              onRetry={() => start(r.id, r.file)}\n            />\n          ))}\n        </ul>\n      )}\n\n      <p role=\"status\" className=\"sr-only\">\n        {said}\n      </p>\n    </div>\n  );\n}\n\nfunction FileRow({\n  row,\n  reduced,\n  disabled,\n  canUpload,\n  onRemove,\n  onRetry,\n}: {\n  row: Row;\n  reduced: boolean;\n  disabled: boolean;\n  canUpload: boolean;\n  onRemove: () => void;\n  onRetry: () => void;\n}) {\n  const [open, setOpen] = React.useState(reduced);\n  React.useEffect(() => {\n    if (open) return;\n    let b = 0;\n    const a = requestAnimationFrame(() => {\n      b = requestAnimationFrame(() => setOpen(true));\n    });\n    return () => {\n      cancelAnimationFrame(a);\n      cancelAnimationFrame(b);\n    };\n  }, [open]);\n  const shown = open && !row.closing;\n  return (\n    <li\n      data-file={row.id}\n      className=\"grid\"\n      inert={row.closing}\n      aria-hidden={row.closing || undefined}\n      style={{\n        gridTemplateRows: shown ? \"1fr\" : \"0fr\",\n        opacity: shown ? 1 : 0,\n        transition: reduced\n          ? \"none\"\n          : \"grid-template-rows 420ms \" +\n            EASE_OUT +\n            \", opacity 200ms \" +\n            EASE_OUT,\n      }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <div className=\"pb-2\">\n          <MembraneAttachment\n            file={row.file}\n            status={row.status}\n            progress={row.progress}\n            error={row.error}\n            disabled={disabled}\n            onRemove={onRemove}\n            onRetry={canUpload ? onRetry : undefined}\n            onUpload={canUpload ? onRetry : undefined}\n          />\n        </div>\n      </div>\n    </li>\n  );\n}\n\nfunction DrawnCheck({ on, reduced }: { on: boolean; reduced: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className=\"size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.75\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path\n        d=\"M3.5 8.5l3 3 6-7\"\n        pathLength={1}\n        strokeDasharray=\"1\"\n        strokeDashoffset={on ? 0 : 1}\n        style={{\n          transition: reduced\n            ? \"none\"\n            : `stroke-dashoffset ${on ? 420 : 0}ms ${EASE_OUT} ${on ? 120 : 0}ms`,\n        }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/use-membrane.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/*\n * The drop zone's membrane. Its border is a ring of points round a rounded rectangle, each free\n * to move along its outward normal. Every point is held to its goal by a spring and to its\n * neighbours by tension, so a pull in one place makes a smooth tent and a release sends a ripple\n * both ways round the edge. One frame loop integrates it and writes the path; it sleeps when the\n * line is still, and a line at rest is drawn as an exact rounded rectangle.\n *\n *   reach    a file outside pulls the nearest stretch of border out to meet it; close to the\n *            edge the bulge reaches past the file, so it is held before it crosses\n *   cross    once the file is inside the stretched line lets go and wobbles back\n *   press    inside, near an edge, the file presses the border out\n *   recoil   a file the zone can't take makes the line shrink away from it\n *   carry    a file leaving through the border drags it along until it slips off\n *   gulp     a drop pulls the whole border in and rings out from the drop point\n *   spit     a refused drop is pushed back out where it landed\n */\n\nexport type MembraneForce = {\n  x: number;\n  y: number;\n  kind: \"reach\" | \"recoil\" | \"carry\";\n  spread?: number;\n};\n\ntype Ring = {\n  count: number;\n  w: number;\n  h: number;\n  r: number;\n  bx: Float32Array;\n  by: Float32Array;\n  nx: Float32Array;\n  ny: Float32Array;\n  u: Float32Array;\n  v: Float32Array;\n  goal: Float32Array;\n  acc: Float32Array;\n};\n\nconst STEP = 1 / 240;\nconst SPRING = 220; // toward the goal\nconst TENSION = 15000; // toward the neighbours: a pull makes a tent, a release travels as a ripple\nconst DRAG = 6.5;\nconst VISCOSITY = 58; // damping between neighbours, so short wobbles die before long ones\nconst SPACING = 5; // px of border per point\nconst REACH = 200; // how far out a file starts to pull\nconst PULL = 44; // goal at the line itself; tension spreads it, so the line reaches about half of it\nconst SLIP = 32; // how far a carried file drags the line before it slips off\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/** The exact rest shape, on half pixels so the 1px line is crisp. */\nfunction restPath(w: number, h: number, radius: number) {\n  const x0 = 0.5,\n    y0 = 0.5,\n    x1 = w - 0.5,\n    y1 = h - 0.5;\n  const r = Math.max(0, Math.min(radius - 0.5, (x1 - x0) / 2, (y1 - y0) / 2));\n  return `M${x0 + r},${y0}H${x1 - r}A${r},${r} 0 0 1 ${x1},${y0 + r}V${y1 - r}A${r},${r} 0 0 1 ${x1 - r},${y1}H${x0 + r}A${r},${r} 0 0 1 ${x0},${y1 - r}V${y0 + r}A${r},${r} 0 0 1 ${x0 + r},${y0}Z`;\n}\n\n/** Points spaced evenly along the rounded rectangle, with their outward normals. */\nfunction buildRing(w: number, h: number, radius: number): Ring {\n  const x0 = 0.5,\n    y0 = 0.5,\n    x1 = w - 0.5,\n    y1 = h - 0.5;\n  const r = Math.max(0, Math.min(radius - 0.5, (x1 - x0) / 2, (y1 - y0) / 2));\n  const sw = x1 - x0 - 2 * r,\n    sh = y1 - y0 - 2 * r,\n    arc = (Math.PI * r) / 2;\n  const length = 2 * sw + 2 * sh + 4 * arc;\n  const count = Math.max(48, Math.min(360, Math.round(length / SPACING)));\n  const ring: Ring = {\n    count,\n    w,\n    h,\n    r,\n    bx: new Float32Array(count),\n    by: new Float32Array(count),\n    nx: new Float32Array(count),\n    ny: new Float32Array(count),\n    u: new Float32Array(count),\n    v: new Float32Array(count),\n    goal: new Float32Array(count),\n    acc: new Float32Array(count),\n  };\n  // Edges and corners clockwise from the top-left end of the top edge.\n  const parts: {\n    len: number;\n    at: (t: number) => [number, number, number, number];\n  }[] = [\n    { len: sw, at: (t) => [x0 + r + t * sw, y0, 0, -1] },\n    {\n      len: arc,\n      at: (t) => corner(x1 - r, y0 + r, -Math.PI / 2 + (t * Math.PI) / 2),\n    },\n    { len: sh, at: (t) => [x1, y0 + r + t * sh, 1, 0] },\n    { len: arc, at: (t) => corner(x1 - r, y1 - r, (t * Math.PI) / 2) },\n    { len: sw, at: (t) => [x1 - r - t * sw, y1, 0, 1] },\n    {\n      len: arc,\n      at: (t) => corner(x0 + r, y1 - r, Math.PI / 2 + (t * Math.PI) / 2),\n    },\n    { len: sh, at: (t) => [x0, y1 - r - t * sh, -1, 0] },\n    {\n      len: arc,\n      at: (t) => corner(x0 + r, y0 + r, Math.PI + (t * Math.PI) / 2),\n    },\n  ];\n  function corner(\n    cx: number,\n    cy: number,\n    a: number,\n  ): [number, number, number, number] {\n    const c = Math.cos(a),\n      s = Math.sin(a);\n    return [cx + c * r, cy + s * r, c, s];\n  }\n  for (let i = 0; i < count; i++) {\n    let s = (i / count) * length;\n    let k = 0;\n    while (k < parts.length - 1 && s > parts[k].len) s -= parts[k++].len;\n    const [x, y, nx, ny] = parts[k].at(\n      parts[k].len ? Math.min(1, s / parts[k].len) : 0,\n    );\n    ring.bx[i] = x;\n    ring.by[i] = y;\n    ring.nx[i] = nx;\n    ring.ny[i] = ny;\n  }\n  return ring;\n}\n\nexport function useMembrane({\n  radius,\n  reduced,\n}: {\n  radius: number;\n  reduced: boolean;\n}) {\n  const box = React.useRef<HTMLElement | null>(null);\n  const path = React.useRef<SVGPathElement>(null);\n  const arrow = React.useRef<HTMLSpanElement>(null);\n  const ring = React.useRef<Ring | null>(null);\n  const forces = React.useRef(\n    new Map<string, MembraneForce & { inside: boolean }>(),\n  );\n  const swell = React.useRef(0);\n  const frame = React.useRef(0);\n  const last = React.useRef(0);\n  const turn = React.useRef({ angle: 0, lean: 0, x: 0, y: 0 });\n  const still = React.useRef(reduced);\n  still.current = reduced;\n\n  const draw = React.useCallback(\n    (rest: boolean) => {\n      const g = ring.current,\n        el = path.current;\n      if (!g || !el) return;\n      if (rest) return el.setAttribute(\"d\", restPath(g.w, g.h, radius));\n      const { count: n, bx, by, nx, ny, u } = g;\n      // Quadratic curves through the midpoints: smooth, and straight where the line is straight.\n      const px = (i: number) => bx[i] + nx[i] * u[i];\n      const py = (i: number) => by[i] + ny[i] * u[i];\n      let d = `M${round((px(n - 1) + px(0)) / 2)},${round((py(n - 1) + py(0)) / 2)}`;\n      for (let i = 0; i < n; i++) {\n        const j = i === n - 1 ? 0 : i + 1;\n        d += `Q${round(px(i))},${round(py(i))} ${round((px(i) + px(j)) / 2)},${round((py(i) + py(j)) / 2)}`;\n      }\n      el.setAttribute(\"d\", `${d}Z`);\n    },\n    [radius],\n  );\n\n  const tick = React.useCallback(\n    (now: number) => {\n      frame.current = 0;\n      const g = ring.current;\n      if (!g) return;\n      const dt = last.current\n        ? Math.min(0.05, (now - last.current) / 1000)\n        : 1 / 60;\n      last.current = now;\n      const { count: n, w, h, bx, by, nx, ny, u, v, goal, acc } = g;\n\n      // Goals: each force bends the stretch of line nearest it; where two overlap the stronger wins.\n      goal.fill(0);\n      let pointer: (MembraneForce & { inside: boolean }) | null = null;\n      for (const f of forces.current.values()) {\n        if (f.kind !== \"carry\") pointer = f;\n        const sigma = f.spread ?? 38,\n          two = 2 * sigma * sigma,\n          far = 9 * sigma * sigma;\n        const inside = f.x > 0 && f.x < w && f.y > 0 && f.y < h;\n        for (let i = 0; i < n; i++) {\n          const dx = f.x - bx[i],\n            dy = f.y - by[i];\n          const s = dx * nx[i] + dy * ny[i];\n          const lateral = dx * dx + dy * dy - s * s;\n          if (lateral > far || (!inside && s < -6)) continue;\n          const fall = Math.exp(-lateral / two);\n          let want: number;\n          if (f.kind === \"reach\")\n            want = inside\n              ? 10 * Math.exp(s / 28)\n              : s < REACH\n                ? PULL * (1 - Math.max(0, s) / REACH) ** 2\n                : 0;\n          else if (f.kind === \"recoil\")\n            want = inside\n              ? -7 * Math.exp(s / 28)\n              : s < REACH\n                ? -12 * (1 - Math.max(0, s) / REACH) ** 2\n                : 0;\n          else\n            want = inside\n              ? 18 * Math.exp(s / 22)\n              : s < SLIP\n                ? (s + 6) * 1.6\n                : 0;\n          want *= fall;\n          if (Math.abs(want) > Math.abs(goal[i])) goal[i] = want;\n        }\n        // Crossing into the zone releases the stretched line: kick it back so it snaps rather than eases.\n        if (f.kind === \"reach\" && inside && !f.inside) {\n          for (let i = 0; i < n; i++) {\n            const dx = f.x - bx[i],\n              dy = f.y - by[i];\n            v[i] -= 150 * Math.exp(-(dx * dx + dy * dy) / (2 * 60 * 60));\n          }\n        }\n        f.inside = inside;\n      }\n      if (swell.current) for (let i = 0; i < n; i++) goal[i] += swell.current;\n\n      // Integrate in fixed small steps; the tension is stiff.\n      for (let t = 0; t < dt - 1e-6; t += STEP) {\n        const h2 = Math.min(STEP, dt - t);\n        for (let i = 0; i < n; i++) {\n          const l = i === 0 ? n - 1 : i - 1,\n            r = i === n - 1 ? 0 : i + 1;\n          acc[i] =\n            SPRING * (goal[i] - u[i]) +\n            TENSION * (u[l] + u[r] - 2 * u[i]) -\n            DRAG * v[i] +\n            VISCOSITY * (v[l] + v[r] - 2 * v[i]);\n        }\n        for (let i = 0; i < n; i++) {\n          v[i] += acc[i] * h2;\n          u[i] += v[i] * h2;\n        }\n      }\n\n      let moving = false,\n        flat = true;\n      for (let i = 0; i < n; i++) {\n        if (Math.abs(u[i] - goal[i]) > 0.03 || Math.abs(v[i]) > 0.4)\n          moving = true;\n        if (Math.abs(goal[i]) > 0.001 || Math.abs(u[i]) > 0.03) flat = false;\n      }\n      if (!moving && flat) {\n        u.fill(0);\n        v.fill(0);\n      }\n      draw(!moving && flat);\n\n      // The arrow watches a file outside, points into the zone once it is over it, and rests pointing up.\n      const a = arrow.current,\n        tr = turn.current;\n      let angle = 0,\n        lean = 0;\n      if (pointer) {\n        const cx = w / 2,\n          cy = h / 2,\n          dx = pointer.x - cx,\n          dy = pointer.y - cy;\n        angle = pointer.inside\n          ? pointer.kind === \"reach\"\n            ? 180\n            : 0\n          : (Math.atan2(dy, dx) * 180) / Math.PI + 90;\n        lean = pointer.inside ? 0 : Math.min(4, Math.hypot(dx, dy) / 40);\n        tr.x = dx;\n        tr.y = dy;\n      }\n      const k = 1 - Math.exp(-dt / 0.08);\n      let delta = ((angle - tr.angle + 540) % 360) - 180;\n      if (Math.abs(delta) < 0.05) delta = 0;\n      tr.angle += delta * k;\n      tr.lean += (lean - tr.lean) * k;\n      if (Math.abs(delta) > 0.05 || Math.abs(lean - tr.lean) > 0.02)\n        moving = true;\n      if (a) {\n        const m = Math.hypot(tr.x, tr.y) || 1;\n        a.style.transform = `translate3d(${round((tr.x / m) * tr.lean)}px,${round((tr.y / m) * tr.lean)}px,0) rotate(${round(tr.angle)}deg)`;\n      }\n\n      if (moving) frame.current = requestAnimationFrame(tick);\n      else last.current = 0;\n    },\n    [draw],\n  );\n\n  const kick = React.useCallback(() => {\n    if (still.current) return;\n    if (!frame.current) frame.current = requestAnimationFrame(tick);\n  }, [tick]);\n\n  // Build the ring from the box's size and rebuild it when the box resizes.\n  const attach = React.useCallback((el: HTMLElement | null) => {\n    box.current = el;\n  }, []);\n  React.useLayoutEffect(() => {\n    const el = box.current;\n    if (!el) return;\n    const measure = () => {\n      const w = el.offsetWidth,\n        h = el.offsetHeight;\n      if (\n        !w ||\n        !h ||\n        (ring.current && ring.current.w === w && ring.current.h === h)\n      )\n        return;\n      ring.current = buildRing(w, h, radius);\n      draw(true);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => {\n      observer.disconnect();\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n    };\n  }, [radius, draw]);\n\n  React.useEffect(() => {\n    if (!reduced) return;\n    cancelAnimationFrame(frame.current);\n    frame.current = 0;\n    last.current = 0;\n    turn.current = { angle: 0, lean: 0, x: 0, y: 0 };\n    // Reduced motion: no bending. Settle everything where it is.\n    forces.current.clear();\n    swell.current = 0;\n    if (ring.current) {\n      ring.current.u.fill(0);\n      ring.current.v.fill(0);\n    }\n    draw(true);\n    if (arrow.current) arrow.current.style.transform = \"\";\n  }, [reduced, draw]);\n\n  const impulse = React.useCallback(\n    (x: number, y: number, base: number, peak: number, sigma: number) => {\n      const g = ring.current;\n      if (!g || still.current) return;\n      for (let i = 0; i < g.count; i++) {\n        const dx = x - g.bx[i],\n          dy = y - g.by[i];\n        g.v[i] +=\n          base + peak * Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));\n      }\n      kick();\n    },\n    [kick],\n  );\n\n  return React.useMemo(\n    () => ({\n      attach,\n      path,\n      arrow,\n      /** Set, move or (with null) remove one force, in the box's own coordinates. */\n      set(key: string, force: MembraneForce | null) {\n        if (still.current) return;\n        const prev = forces.current.get(key);\n        if (force)\n          forces.current.set(key, {\n            ...force,\n            inside:\n              prev?.inside ??\n              (force.x > 0 &&\n                force.y > 0 &&\n                force.x < (ring.current?.w ?? 0) &&\n                force.y < (ring.current?.h ?? 0)),\n          });\n        else if (prev) forces.current.delete(key);\n        else return;\n        kick();\n      },\n      /** Push the whole line out (positive) or in by a few pixels. */\n      swell(px: number) {\n        if (swell.current === px) return;\n        swell.current = px;\n        kick();\n      },\n      gulp(x: number, y: number) {\n        impulse(x, y, -70, -230, 90);\n      },\n      spit(x: number, y: number) {\n        impulse(x, y, 0, 320, 64);\n      },\n    }),\n    [attach, kick, impulse],\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/ui-interaction-motion.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport const EASE_OUT = \"cubic-bezier(0.16, 1, 0.3, 1)\";\nexport const EASE_SETTLE = \"cubic-bezier(0.22, 1.18, 0.36, 1)\";\nexport const INTERACTION_FOCUS =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\n\nconst query = \"(prefers-reduced-motion: reduce)\";\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", notify);\n  return () => media.removeEventListener(\"change\", notify);\n};\nexport const useInteractionReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) =>\n      setVisible(entry.isIntersecting),\n    );\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility();\n    document.addEventListener(\"visibilitychange\", visibility);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", visibility);\n    };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(\n  on: boolean,\n  reduced: boolean,\n): React.CSSProperties {\n  return {\n    opacity: on ? 1 : 0,\n    transform: on ? \"none\" : \"scale(0.6)\",\n    filter: on ? \"blur(0px)\" : \"blur(3px)\",\n    transition: reduced\n      ? \"none\"\n      : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}`,\n  };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(\n  samples: PointerSample[],\n  x: number,\n  time: number,\n) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100)\n    return 0;\n  const first = samples[0],\n    last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 520,\n) {\n  const omega = 20,\n    damping = 0.82,\n    damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient =\n    (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = ((i / 32) * duration) / 1000;\n    const value =\n      to +\n      Math.exp(-damping * omega * t) *\n        (displacement * Math.cos(damped * t) +\n          coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/text-morph.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * TEXT MORPH: text that changes the way it should\n *\n *   shared   letters both texts share stay alive and slide to\n *            their new place (\"Save\" → \"Saving\" keeps \"Sav\")\n *   new      letters that weren't there rise in out of a blur,\n *            one after another\n *   gone     letters that leave lift away and blur out where\n *            they stood\n *   width    the box eases to its new width, so a button or\n *            pill holding it resizes with it\n *\n * Texts with little in common crossfade as a whole instead of\n * flinging single letters across. The real text is always\n * there for screen readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface TextMorphProps extends Omit<\n  React.HTMLAttributes<HTMLSpanElement>,\n  \"children\"\n> {\n  children: string;\n  /** Morph time in ms. */\n  duration?: number;\n  /** Where new letters come from: below (\"up\") or above (\"down\"). */\n  direction?: \"up\" | \"down\";\n  /** Ease the box's width too; turn off for text that wraps. */\n  animateWidth?: boolean;\n}\n\ntype Glyph = { key: number; char: string };\ntype Leaving = Glyph & { x: number; y: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\n/** Keep the letters two texts share (longest common subsequence); everything else is new. */\nfunction morph(\n  previous: Glyph[],\n  text: string,\n  nextKey: () => number,\n): Glyph[] {\n  const a = previous.map((g) => g.char);\n  const b = Array.from(text);\n  const fresh = () => b.map((char) => ({ key: nextKey(), char }));\n  if (!a.length || !b.length) return fresh();\n\n  const dp = Array.from(\n    { length: a.length + 1 },\n    () => new Uint16Array(b.length + 1),\n  );\n  for (let i = a.length - 1; i >= 0; i--)\n    for (let j = b.length - 1; j >= 0; j--)\n      dp[i][j] =\n        a[i] === b[j]\n          ? dp[i + 1][j + 1] + 1\n          : Math.max(dp[i + 1][j], dp[i][j + 1]);\n\n  // Too little in common reads as two different words: crossfade them instead.\n  if (dp[0][0] < Math.min(a.length, b.length) * 0.5) return fresh();\n\n  const out: Glyph[] = [];\n  let i = 0;\n  let j = 0;\n  while (j < b.length) {\n    if (i < a.length && a[i] === b[j]) {\n      out.push(previous[i++]);\n      j++;\n    } else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) i++;\n    else out.push({ key: nextKey(), char: b[j++] });\n  }\n  return out;\n}\n\nfunction LeavingGlyph({\n  glyph,\n  lift,\n  duration,\n  onDone,\n}: {\n  glyph: Leaving;\n  lift: number;\n  duration: number;\n  onDone: (key: number) => void;\n}) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const animation = el.animate(\n      [\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n        { opacity: 0, filter: \"blur(4px)\", transform: `translateY(${lift}em)` },\n      ],\n      { duration: duration * 0.6, easing: EASE, fill: \"forwards\" },\n    );\n    animation.onfinish = () => onDone(glyph.key);\n    return () => animation.cancel();\n  }, [glyph.key, lift, duration, onDone]);\n  return (\n    <span\n      ref={ref}\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute whitespace-pre\"\n      style={{ left: glyph.x, top: glyph.y }}\n    >\n      {glyph.char}\n    </span>\n  );\n}\n\nexport function TextMorph({\n  children: text,\n  duration = 460,\n  direction = \"up\",\n  animateWidth = true,\n  className = \"\",\n  ...props\n}: TextMorphProps) {\n  const reduced = useReducedMotion();\n  const counter = React.useRef(text.length);\n  const nextKey = React.useCallback(() => ++counter.current, []);\n  const [glyphs, setGlyphs] = React.useState<Glyph[]>(() =>\n    Array.from(text).map((char, i) => ({ key: i + 1, char })),\n  );\n  const [leaving, setLeaving] = React.useState<Leaving[]>([]);\n  const rootRef = React.useRef<HTMLSpanElement>(null);\n  const els = React.useRef(new Map<number, HTMLSpanElement>());\n  const before = React.useRef<{\n    at: Map<number, { x: number; y: number }>;\n    width: number;\n  } | null>(null);\n  const rise = direction === \"up\" ? 0.35 : -0.35;\n\n  // A preference change must also finish an in-flight morph whose target text\n  // already matches the glyphs (including the empty label in a folded receipt).\n  React.useLayoutEffect(() => {\n    if (!reduced) return;\n    before.current = null;\n    const root = rootRef.current;\n    root\n      ?.getAnimations({ subtree: true })\n      .forEach((animation) => animation.cancel());\n    if (root) root.style.whiteSpace = \"\";\n    setLeaving((previous) => (previous.length ? [] : previous));\n  }, [reduced]);\n\n  // 1. The text changed: note where every letter stands, then work out which letters stay.\n  React.useLayoutEffect(() => {\n    if (text === glyphs.map((g) => g.char).join(\"\")) return;\n    const root = rootRef.current;\n    if (!root || reduced) {\n      setLeaving([]);\n      setGlyphs(Array.from(text).map((char) => ({ key: nextKey(), char })));\n      return;\n    }\n    const box = root.getBoundingClientRect();\n    const at = new Map<number, { x: number; y: number }>();\n    els.current.forEach((el, key) => {\n      const r = el.getBoundingClientRect();\n      at.set(key, { x: r.left - box.left, y: r.top - box.top });\n    });\n    before.current = { at, width: box.width };\n    const next = morph(glyphs, text, nextKey);\n    const kept = new Set(next.map((g) => g.key));\n    setLeaving((l) => [\n      ...l,\n      ...glyphs\n        .filter((g) => !kept.has(g.key))\n        .map((g) => ({ ...g, ...(at.get(g.key) ?? { x: 0, y: 0 }) })),\n    ]);\n    setGlyphs(next);\n  }, [text, reduced]);\n\n  // 2. The new letters are in place: glide the ones that stayed, raise the new ones, ease the width.\n  React.useLayoutEffect(() => {\n    const prev = before.current;\n    const root = rootRef.current;\n    if (!prev || !root) return;\n    before.current = null;\n    const box = root.getBoundingClientRect();\n    let order = 0;\n    for (const g of glyphs) {\n      const el = els.current.get(g.key);\n      if (!el) continue;\n      const from = prev.at.get(g.key);\n      if (from) {\n        const r = el.getBoundingClientRect();\n        const dx = from.x - (r.left - box.left);\n        const dy = from.y - (r.top - box.top);\n        if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)\n          el.animate(\n            [\n              { transform: `translate(${dx}px, ${dy}px)` },\n              { transform: \"none\" },\n            ],\n            { duration, easing: EASE },\n          );\n      } else {\n        el.animate(\n          [\n            {\n              opacity: 0,\n              filter: \"blur(4px)\",\n              transform: `translateY(${rise}em)`,\n            },\n            { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n          ],\n          {\n            duration,\n            easing: EASE,\n            delay: Math.min(order++ * 16, 160),\n            fill: \"backwards\",\n          },\n        );\n      }\n    }\n    if (animateWidth && Math.abs(prev.width - box.width) > 0.5) {\n      // Hold the words on one line while the box is narrower than them, or \"No credits left\" would wrap mid-morph.\n      root.style.whiteSpace = \"nowrap\";\n      const resize = root.animate(\n        [{ width: `${prev.width}px` }, { width: `${box.width}px` }],\n        { duration, easing: EASE },\n      );\n      resize.onfinish = resize.oncancel = () => {\n        root.style.whiteSpace = \"\";\n      };\n    }\n  }, [glyphs, duration, rise, animateWidth]);\n\n  const done = React.useCallback(\n    (key: number) => setLeaving((l) => l.filter((g) => g.key !== key)),\n    [],\n  );\n\n  // Words stay unbroken; lines may only wrap at spaces.\n  const words: Glyph[][] = [];\n  for (const g of glyphs) {\n    if (\n      g.char === \" \" ||\n      !words.length ||\n      words[words.length - 1][0]?.char === \" \"\n    )\n      words.push([g]);\n    else words[words.length - 1].push(g);\n  }\n\n  return (\n    <span\n      ref={rootRef}\n      className={`relative inline-block ${className}`}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      {words.map((word, w) => (\n        <span\n          key={w}\n          aria-hidden=\"true\"\n          className=\"inline-block whitespace-pre\"\n        >\n          {word.map((g) => (\n            <span\n              key={g.key}\n              ref={(el) => {\n                if (el) els.current.set(g.key, el);\n                else els.current.delete(g.key);\n              }}\n              className=\"inline-block\"\n            >\n              {g.char}\n            </span>\n          ))}\n        </span>\n      ))}\n      {leaving.map((g) => (\n        <LeavingGlyph\n          key={`out-${g.key}`}\n          glyph={g}\n          lift={-rise}\n          duration={duration}\n          onDone={done}\n        />\n      ))}\n    </span>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/membrane-attachment.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useIsPresent } from \"motion/react\";\nimport {\n  ArrowUp,\n  Clock,\n  FileWarning,\n  FileText,\n  LoaderCircle,\n  RotateCcw,\n  X,\n} from \"lucide-react\";\nimport {\n  Attachment,\n  AttachmentAction,\n  AttachmentActions,\n  AttachmentContent,\n  AttachmentDescription,\n  AttachmentMedia,\n  AttachmentTitle,\n} from \"./attachment\";\nimport { NumberRoll } from \"./number-roll\";\nimport { useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\nexport type MembraneAttachmentStatus =\n  \"idle\" | \"uploading\" | \"processing\" | \"error\" | \"done\";\n\nexport interface MembraneAttachmentProps {\n  file: Pick<File, \"name\" | \"size\">;\n  status: MembraneAttachmentStatus;\n  /** Upload progress from 0 to 1. */\n  progress?: number;\n  error?: string;\n  disabled?: boolean;\n  onRemove?: () => void;\n  onRetry?: () => void;\n  onUpload?: () => void;\n}\n\nexport function formatBytes(n: number) {\n  return n < 1000\n    ? n + \" B\"\n    : n < 1e6\n      ? Math.round(n / 100) / 10 + \" KB\"\n      : (n / 1e6).toFixed(n < 1e7 ? 1 : 0) + \" MB\";\n}\n\nconst SWAP = { duration: 0.2, ease: [0.22, 1, 0.36, 1] as const };\nconst INSTANT = { duration: 0 };\nconst PROGRESS = {\n  type: \"spring\" as const,\n  stiffness: 300,\n  damping: 35,\n  mass: 0.8,\n  restDelta: 0.001,\n};\nconst ICONS = {\n  idle: Clock,\n  uploading: LoaderCircle,\n  processing: FileText,\n  error: FileWarning,\n};\n\n/** Progress updates do not restart or re-render the status icon animation. */\nconst StatusIcon = React.memo(function StatusIcon({\n  status,\n  reduced,\n}: {\n  status: MembraneAttachmentStatus;\n  reduced: boolean;\n}) {\n  const Icon = status === \"done\" ? null : ICONS[status];\n  const content = Icon ? (\n    <Icon\n      className={\n        \"size-4 \" + (status === \"uploading\" ? \"motion-safe:animate-spin\" : \"\")\n      }\n      strokeWidth={1.75}\n    />\n  ) : (\n    <motion.svg\n      viewBox=\"0 0 16 16\"\n      className=\"size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.75\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <motion.path\n        d=\"M3.5 8.5l3 3 6-7\"\n        initial={reduced ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={reduced ? INSTANT : { ...SWAP, duration: 0.25 }}\n      />\n    </motion.svg>\n  );\n  if (reduced)\n    return (\n      <span\n        aria-hidden=\"true\"\n        data-attachment-icon={status}\n        className=\"absolute inset-0 flex items-center justify-center\"\n      >\n        {content}\n      </span>\n    );\n  return (\n    <AnimatePresence initial={false}>\n      <motion.span\n        key={status}\n        aria-hidden=\"true\"\n        data-attachment-icon={status}\n        className=\"absolute inset-0 flex items-center justify-center\"\n        initial={{ opacity: 0, scale: 0.85 }}\n        animate={{ opacity: 1, scale: 1 }}\n        exit={{ opacity: 0, scale: 0.85 }}\n        transition={SWAP}\n      >\n        {content}\n      </motion.span>\n    </AnimatePresence>\n  );\n});\n\n/** Measure only text reflow (state, wrapping or font changes), never animation frames. */\nfunction StatusDescription({\n  status,\n  description,\n  pct,\n  reduced,\n}: {\n  status: MembraneAttachmentStatus;\n  description: string;\n  pct: number;\n  reduced: boolean;\n}) {\n  const measure = React.useRef<HTMLSpanElement>(null);\n  const [height, setHeight] = React.useState<number>();\n  React.useLayoutEffect(() => {\n    const node = measure.current;\n    if (!node) return;\n    const resize = () => {\n      const next = node.getBoundingClientRect().height;\n      setHeight((old) => (old === next ? old : next));\n    };\n    resize();\n    const observer = new ResizeObserver(resize);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <motion.span\n      className=\"relative block\"\n      initial={false}\n      animate={{ height: height ?? \"auto\" }}\n      transition={reduced ? INSTANT : SWAP}\n    >\n      {/* Sizing and accessibility use plain current text; exiting text is decorative. */}\n      <span\n        ref={measure}\n        aria-hidden=\"true\"\n        className={\n          \"invisible block \" +\n          (status === \"uploading\" ? \"whitespace-nowrap\" : \"\")\n        }\n      >\n        {status === \"uploading\" ? \"Uploading · 100%\" : description}\n      </span>\n      <span className=\"sr-only\">{description}</span>\n      {reduced ? (\n        <span\n          aria-hidden=\"true\"\n          data-attachment-description={status}\n          className=\"absolute inset-x-0 top-0 block\"\n        >\n          {description}\n        </span>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={status}\n            aria-hidden=\"true\"\n            data-attachment-description={status}\n            className=\"absolute inset-x-0 top-0 block\"\n            initial={{ opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, y: -4 }}\n            transition={SWAP}\n          >\n            {status === \"uploading\" ? (\n              <span className=\"inline-flex items-baseline whitespace-nowrap\">\n                Uploading ·&nbsp;\n                <NumberRoll value={pct} suffix=\"%\" duration={220} />\n              </span>\n            ) : (\n              description\n            )}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nfunction StateAction({\n  kind,\n  name,\n  disabled,\n  reduced,\n  onClick,\n}: {\n  kind: \"idle\" | \"error\";\n  name: string;\n  disabled?: boolean;\n  reduced: boolean;\n  onClick: React.MouseEventHandler<HTMLButtonElement>;\n}) {\n  const present = useIsPresent();\n  const Icon = kind === \"idle\" ? ArrowUp : RotateCcw;\n  return (\n    <motion.span\n      className=\"absolute inset-0 flex items-center justify-center\"\n      inert={!present}\n      aria-hidden={!present || undefined}\n      initial={reduced ? false : { opacity: 0, scale: 0.85 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={{ opacity: 0, scale: 0.85 }}\n      transition={reduced ? INSTANT : SWAP}\n    >\n      <AttachmentAction\n        size=\"icon\"\n        disabled={disabled || !present}\n        onClick={onClick}\n        aria-label={(kind === \"idle\" ? \"Upload \" : \"Retry \") + name}\n      >\n        <Icon />\n      </AttachmentAction>\n    </motion.span>\n  );\n}\n\n/** Attachment with persistent identity and interruptible state transitions. */\nexport function MembraneAttachment({\n  file,\n  status,\n  progress = 0,\n  error,\n  disabled,\n  onRemove,\n  onRetry,\n  onUpload,\n}: MembraneAttachmentProps) {\n  const reduced = useInteractionReducedMotion();\n  const root = React.useRef<HTMLDivElement>(null);\n  const removeButton = React.useRef<HTMLButtonElement>(null);\n  const pct = Math.round(\n    Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 0)) * 100,\n  );\n  const description = {\n    idle: \"Ready to upload\",\n    uploading: \"Uploading · \" + pct + \"%\",\n    processing: \"Processing document\",\n    error: error || \"Upload failed. Try again.\",\n    done: \"Uploaded · \" + formatBytes(file.size),\n  }[status];\n  const action =\n    status === \"idle\" && onUpload\n      ? \"idle\"\n      : status === \"error\" && onRetry\n        ? \"error\"\n        : null;\n  const runAction: React.MouseEventHandler<HTMLButtonElement> = (event) => {\n    // A disappearing action must not leave keyboard focus on the document body.\n    if (event.currentTarget.contains(document.activeElement)) {\n      (removeButton.current ?? root.current)?.focus({ preventScroll: true });\n    }\n    (action === \"idle\" ? onUpload : onRetry)?.();\n  };\n  const uploading = status === \"uploading\";\n  const fraction = uploading\n    ? pct / 100\n    : status === \"processing\" || status === \"done\"\n      ? 1\n      : 0;\n\n  return (\n    <Attachment\n      ref={root}\n      tabIndex={-1}\n      state={status}\n      className=\"block w-full rounded-[14px] border-border duration-200 outline-none motion-reduce:transition-none\"\n    >\n      <div className=\"flex min-w-0 items-start gap-2.5\">\n        <AttachmentMedia className=\"size-10 rounded-[10px] transition-colors duration-200 motion-reduce:transition-none\">\n          <StatusIcon status={status} reduced={reduced} />\n        </AttachmentMedia>\n        <AttachmentContent className=\"pt-0.5\">\n          <AttachmentTitle title={file.name} className=\"text-sm\">\n            {file.name}\n          </AttachmentTitle>\n          <AttachmentDescription className=\"overflow-visible whitespace-normal [overflow-wrap:anywhere] transition-colors duration-200 motion-reduce:transition-none\">\n            <StatusDescription\n              status={status}\n              description={description}\n              pct={pct}\n              reduced={reduced}\n            />\n          </AttachmentDescription>\n        </AttachmentContent>\n        <AttachmentActions className=\"pt-1\">\n          <motion.span\n            className=\"relative block h-8 shrink-0\"\n            initial={false}\n            animate={{ width: action ? 32 : 0 }}\n            transition={reduced ? INSTANT : SWAP}\n          >\n            {reduced ? (\n              action && (\n                <StateAction\n                  key={action}\n                  kind={action}\n                  name={file.name}\n                  reduced\n                  disabled={disabled}\n                  onClick={runAction}\n                />\n              )\n            ) : (\n              <AnimatePresence initial={false}>\n                {action && (\n                  <StateAction\n                    key={action}\n                    kind={action}\n                    name={file.name}\n                    reduced={false}\n                    disabled={disabled}\n                    onClick={runAction}\n                  />\n                )}\n              </AnimatePresence>\n            )}\n          </motion.span>\n          {onRemove && (\n            <AttachmentAction\n              ref={removeButton}\n              size=\"icon\"\n              disabled={disabled}\n              onClick={onRemove}\n              aria-label={\n                (status === \"uploading\" || status === \"processing\"\n                  ? \"Cancel \"\n                  : \"Remove \") + file.name\n              }\n            >\n              <X />\n            </AttachmentAction>\n          )}\n        </AttachmentActions>\n      </div>\n      {/* A persistent tray can reverse mid-collapse; height animates without scaling any text. */}\n      <motion.div\n        data-attachment-progress-tray\n        aria-hidden={!uploading}\n        className=\"overflow-hidden px-1\"\n        initial={false}\n        animate={{ height: uploading ? 16 : 0, opacity: uploading ? 1 : 0 }}\n        transition={\n          reduced\n            ? INSTANT\n            : {\n                height: {\n                  ...SWAP,\n                  duration: 0.24,\n                  delay: status === \"processing\" ? 0.12 : 0,\n                },\n                opacity: { ...SWAP, delay: status === \"processing\" ? 0.12 : 0 },\n              }\n        }\n      >\n        <div className=\"pt-2\">\n          <div\n            className=\"h-1 overflow-hidden rounded-full bg-muted\"\n            role={uploading ? \"progressbar\" : undefined}\n            aria-label={uploading ? \"Uploading \" + file.name : undefined}\n            aria-valuemin={uploading ? 0 : undefined}\n            aria-valuemax={uploading ? 100 : undefined}\n            aria-valuenow={uploading ? pct : undefined}\n          >\n            <motion.div\n              data-attachment-progress\n              className=\"h-full origin-left rounded-full bg-foreground\"\n              initial={false}\n              animate={{ scaleX: fraction }}\n              transition={reduced ? INSTANT : PROGRESS}\n            />\n          </div>\n        </div>\n      </motion.div>\n    </Attachment>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/attachment.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"cn\";\n\nimport { Button } from \"./attachment-button\";\n\nconst attachmentVariants = cva(\n  \"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-[14px] border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/30 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed\",\n  {\n    variants: {\n      size: {\n        default:\n          \"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2\",\n        sm: \"gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5\",\n        xs: \"gap-1.5 rounded-xl text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1\",\n      },\n      orientation: {\n        horizontal: \"min-w-40 items-center\",\n        vertical: \"w-24 flex-col has-data-[slot=attachment-content]:w-30\",\n      },\n    },\n  },\n);\n\nfunction Attachment({\n  className,\n  state = \"done\",\n  size = \"default\",\n  orientation = \"horizontal\",\n  ...props\n}: React.ComponentProps<\"div\"> &\n  VariantProps<typeof attachmentVariants> & {\n    state?: \"idle\" | \"uploading\" | \"processing\" | \"error\" | \"done\";\n  }) {\n  return (\n    <div\n      data-slot=\"attachment\"\n      data-state={state}\n      data-size={size}\n      data-orientation={orientation}\n      className={cn(attachmentVariants({ size, orientation }), className)}\n      {...props}\n    />\n  );\n}\n\nconst attachmentMediaVariants = cva(\n  \"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5\",\n  {\n    variants: {\n      variant: {\n        icon: \"\",\n        image:\n          \"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover\",\n      },\n    },\n    defaultVariants: {\n      variant: \"icon\",\n    },\n  },\n);\n\nfunction AttachmentMedia({\n  className,\n  variant = \"icon\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof attachmentMediaVariants>) {\n  return (\n    <div\n      data-slot=\"attachment-media\"\n      data-variant={variant}\n      className={cn(attachmentMediaVariants({ variant }), className)}\n      {...props}\n    />\n  );\n}\n\nfunction AttachmentContent({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"attachment-content\"\n      className={cn(\n        \"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AttachmentTitle({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"attachment-title\"\n      className={cn(\n        \"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AttachmentDescription({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"attachment-description\"\n      className={cn(\n        \"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80\",\n        \"max-w-full\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AttachmentActions({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"attachment-actions\"\n      className={cn(\n        \"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AttachmentAction({\n  className,\n  variant,\n  size = \"icon-xs\",\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  return (\n    <Button\n      data-slot=\"attachment-action\"\n      variant={variant ?? \"ghost\"}\n      size={size}\n      className={cn(className)}\n      {...props}\n    />\n  );\n}\n\nfunction AttachmentTrigger({\n  className,\n  render,\n  type,\n  ...props\n}: useRender.ComponentProps<\"button\">) {\n  return useRender({\n    defaultTagName: \"button\",\n    props: mergeProps<\"button\">(\n      {\n        type: render ? type : (type ?? \"button\"),\n        className: cn(\"absolute inset-0 z-10 outline-none\", className),\n      },\n      props,\n    ),\n    render,\n    state: {\n      slot: \"attachment-trigger\",\n    },\n  });\n}\n\nfunction AttachmentGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"attachment-group\"\n      className={cn(\n        \"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Attachment,\n  AttachmentGroup,\n  AttachmentMedia,\n  AttachmentContent,\n  AttachmentTitle,\n  AttachmentDescription,\n  AttachmentActions,\n  AttachmentAction,\n  AttachmentTrigger,\n};\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/attachment-button.tsx",
      "content": "\"use client\";\n\nimport { Button as ButtonPrimitive } from \"@base-ui/react/button\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"cn\";\n\nconst buttonVariants = cva(\n  \"group/button inline-flex shrink-0 items-center justify-center rounded-[14px] border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground hover:bg-primary/80\",\n        outline:\n          \"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30\",\n        secondary:\n          \"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground\",\n        ghost:\n          \"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50\",\n        destructive:\n          \"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default:\n          \"h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5\",\n        xs: \"h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3\",\n        sm: \"h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2\",\n        lg: \"h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3\",\n        icon: \"size-8\",\n        \"icon-xs\": \"size-6 [&_svg:not([class*='size-'])]:size-3\",\n        \"icon-sm\": \"size-7\",\n        \"icon-lg\": \"size-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nfunction Button({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  ...props\n}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {\n  return (\n    <ButtonPrimitive\n      data-slot=\"button\"\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  );\n}\n\nexport { Button, buttonVariants };\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/number-roll.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * NUMBER ROLL: numbers that change like an odometer\n *\n *   rolling   each digit is a column that rolls to its new\n *             value, in the direction the number moved: going\n *             up, a 9 rolls on to 0 instead of spinning back\n *   places    new places slide open from nothing; lost ones\n *             fold away\n *   format    anything Intl.NumberFormat does: currency,\n *             percent, compact (\"1.2K\"), decimals\n *\n * The real number is always there as text for screen readers\n * and copy and paste; the columns are only what you see.\n * ───────────────────────────────────────────────────────── */\n\nexport interface NumberRollProps extends Omit<\n  React.HTMLAttributes<HTMLSpanElement>,\n  \"children\"\n> {\n  value: number;\n  /** Intl.NumberFormat options, e.g. { style: \"currency\", currency: \"USD\" }. */\n  format?: Intl.NumberFormatOptions;\n  locales?: string | string[];\n  prefix?: string;\n  suffix?: string;\n  /** Start from this value and roll to `value` once mounted, e.g. 0 for stats. */\n  from?: number;\n  /** Roll duration in ms. */\n  duration?: number;\n  /** Always roll this way, e.g. \"down\" for a countdown's seconds, which wrap from 00 to 59. Defaults to the way the number moved. */\n  direction?: \"up\" | \"down\";\n}\n\ntype Column = { key: string; char: string; digit: number | null };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst SETS = 5; // the strip repeats 0–9 five times so a roll never runs out of digits\nconst MIDDLE = 20;\n/** The strip as one text node, a digit a line: fifty lines, but one element to lay out instead of fifty. */\nconst STRIP = Array.from({ length: SETS * 10 }, (_, i) => i % 10).join(\"\\n\");\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\n/** Digits are keyed by their place counted from the right, so the units column stays the units column. */\nfunction columns(text: string): Column[] {\n  const chars = [...text];\n  const seen = new Map<string, number>();\n  let place = 0;\n  const out: Column[] = [];\n  for (let i = chars.length - 1; i >= 0; i--) {\n    const char = chars[i];\n    if (/\\d/.test(char))\n      out.push({ key: `d${place++}`, char, digit: Number(char) });\n    else {\n      // Symbols are keyed by how many of the same symbol sit to their right, so a comma that shifts is the same comma.\n      const n = seen.get(char) ?? 0;\n      seen.set(char, n + 1);\n      out.push({ key: `s${char}${n}`, char, digit: null });\n    }\n  }\n  return out.reverse();\n}\n\n// Memoised: a parent that re-renders with the same number doesn't rebuild the strips.\nconst Digit = React.memo(function Digit({\n  digit,\n  trend,\n  duration,\n  reduced,\n  rollIn,\n}: {\n  digit: number;\n  trend: number;\n  duration: number;\n  reduced: boolean;\n  rollIn: boolean;\n}) {\n  const [pos, setPos] = React.useState(MIDDLE + (rollIn ? 0 : digit));\n  const [moving, setMoving] = React.useState(false);\n  const posRef = React.useRef(pos);\n  const stripRef = React.useRef<HTMLSpanElement>(null);\n\n  React.useLayoutEffect(() => {\n    const current = posRef.current;\n    const showing = ((current % 10) + 10) % 10;\n    if (showing === digit) return;\n    if (reduced) {\n      posRef.current = MIDDLE + digit;\n      setMoving(false);\n      setPos(MIDDLE + digit);\n      return;\n    }\n    const up = trend > 0 || (trend === 0 && digit > showing);\n    let target = up\n      ? current + ((digit - showing + 10) % 10)\n      : current - ((showing - digit + 10) % 10);\n    // Values arriving faster than a roll (a progress percent) never let it re-centre, so before it runs off the strip,\n    // jump the strip back by whole sets mid-roll (it looks identical) and roll on from there.\n    if (target < 10 || target > SETS * 10 - 11) {\n      const shift = Math.round((target - MIDDLE) / 10) * 10;\n      const strip = stripRef.current;\n      if (strip?.offsetHeight) {\n        let visual = current;\n        try {\n          visual =\n            -new DOMMatrixReadOnly(getComputedStyle(strip).transform).m42 /\n            (strip.offsetHeight / (SETS * 10));\n        } catch {}\n        const transition = strip.style.transition;\n        strip.style.transition = \"none\";\n        strip.style.transform = `translateY(${(-(visual - shift) * 100) / (SETS * 10)}%)`;\n        void strip.offsetHeight;\n        strip.style.transition = transition;\n      }\n      target -= shift;\n    }\n    posRef.current = target;\n    setMoving(true);\n    setPos(target);\n  }, [digit, trend, reduced]);\n\n  // After a roll, quietly re-centre on the middle set so the next roll always has room.\n  const settle = () => {\n    const centred = MIDDLE + (((posRef.current % 10) + 10) % 10);\n    posRef.current = centred;\n    setMoving(false);\n    setPos(centred);\n  };\n\n  return (\n    <span className=\"relative inline-block [clip-path:inset(0)]\">\n      <span className=\"invisible\">{digit}</span>\n      <span\n        ref={stripRef}\n        aria-hidden=\"true\"\n        onTransitionEnd={settle}\n        className=\"absolute inset-x-0 top-0 block whitespace-pre text-center\"\n        style={{\n          transform: `translateY(${(-pos * 100) / (SETS * 10)}%)`,\n          transition: moving ? `transform ${duration}ms ${EASE}` : \"none\",\n        }}\n      >\n        {STRIP}\n      </span>\n    </span>\n  );\n});\n\n/** A place that appeared after the first paint slides open; places that were there from the start just sit. */\nfunction Place({\n  enter,\n  children,\n}: {\n  enter: boolean;\n  children: React.ReactNode;\n}) {\n  const [entering] = React.useState(enter);\n  return (\n    <span\n      className={`inline-block ${entering ? \"[clip-path:inset(0)] animate-[ui-col-in_500ms_cubic-bezier(0.16,1,0.3,1)_both]\" : \"\"}`}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport function NumberRoll({\n  value,\n  format,\n  locales,\n  prefix = \"\",\n  suffix = \"\",\n  from,\n  duration = 900,\n  direction,\n  className = \"\",\n  ...props\n}: NumberRollProps) {\n  const reduced = useReducedMotion();\n  const [shown, setShown] = React.useState(from ?? value);\n  const previous = React.useRef(shown);\n  const mounted = React.useRef(false);\n\n  // Roll from `from` to the real value once on screen (a frame later, so the start is painted).\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setShown(value));\n    return () => cancelAnimationFrame(frame);\n  }, [value]);\n\n  React.useEffect(() => {\n    mounted.current = true;\n  }, []);\n\n  const formatter = React.useMemo(\n    () => new Intl.NumberFormat(locales, format),\n    [locales, format && JSON.stringify(format)],\n  );\n  const text = `${prefix}${formatter.format(shown)}${suffix}`;\n  const cols = React.useMemo(() => columns(text), [text]);\n  const trend =\n    direction === \"up\"\n      ? 1\n      : direction === \"down\"\n        ? -1\n        : Math.sign(shown - previous.current);\n\n  // Places that disappear fold away instead of vanishing.\n  const [leaving, setLeaving] = React.useState<Column[]>([]);\n  const lastCols = React.useRef(cols);\n  React.useLayoutEffect(() => {\n    const gone = lastCols.current.filter(\n      (c) => !cols.some((n) => n.key === c.key),\n    );\n    lastCols.current = cols;\n    previous.current = shown;\n    if (!gone.length || reduced) return;\n    setLeaving(gone);\n    const timer = window.setTimeout(() => setLeaving([]), 420);\n    return () => window.clearTimeout(timer);\n  }, [cols, shown, reduced]);\n\n  return (\n    <span className={`inline-flex tabular-nums ${className}`} {...props}>\n      <span className=\"sr-only\">{`${prefix}${formatter.format(value)}${suffix}`}</span>\n      <span\n        aria-hidden=\"true\"\n        className=\"inline-flex items-baseline whitespace-pre\"\n      >\n        {leaving.map((c) => (\n          <span\n            key={`out-${c.key}`}\n            className=\"inline-block [clip-path:inset(0)] animate-[ui-col-out_400ms_cubic-bezier(0.16,1,0.3,1)_forwards]\"\n          >\n            {c.char}\n          </span>\n        ))}\n        {cols.map((c) => (\n          <Place key={c.key} enter={mounted.current && !reduced}>\n            {c.digit === null ? (\n              c.char\n            ) : (\n              <Digit\n                digit={c.digit}\n                trend={trend}\n                duration={duration}\n                reduced={reduced}\n                rollIn={mounted.current}\n              />\n            )}\n          </Place>\n        ))}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "css": {
    "@utility shimmer": {
      "mask-image": "linear-gradient(110deg, #000 35%, #0006 50%, #000 65%)",
      "mask-size": "250% 100%",
      "animation": "attachment-shimmer 2s linear infinite",
      "@media (prefers-reduced-motion: reduce)": {
        "animation": "none",
        "mask-image": "none"
      }
    },
    "@keyframes attachment-shimmer": {
      "from": {
        "mask-position": "150% 0"
      },
      "to": {
        "mask-position": "-50% 0"
      }
    },
    "@keyframes ui-col-in": {
      "from": {
        "max-width": "0",
        "opacity": "0"
      },
      "to": {
        "max-width": "1em",
        "opacity": "1"
      }
    },
    "@keyframes ui-col-out": {
      "from": {
        "max-width": "1em",
        "opacity": "1"
      },
      "to": {
        "max-width": "0",
        "opacity": "0"
      }
    }
  },
  "meta": {
    "license": "MIT",
    "cavemio": {
      "component": "drop-zone-membrane",
      "exportName": "DropZoneMembrane",
      "usage": "import { DropZoneMembrane } from \"@/components/drop-zone-membrane\";\n\n<DropZoneMembrane accept=\"image/*,.pdf\" maxSize={10_000_000} maxFiles={2} onFilesChange={setFiles} onUpload={uploadFile} />",
      "accessibility": "Native button supports Enter and Space to browse, and focused paste. File updates are announced politely; progress bars are labelled; retry and remove actions name their file. Files appear directly as attachment cards. Reduced motion disables membrane bending, card entry motion and shimmer. Attachment icons and labels animate within a persistent card, and reduced motion uses immediate state changes. Upload and retry actions transfer focus to the stable remove button.",
      "previewNote": "The preview simulates upload progress locally; supply onUpload for a real service. Without it, selected files remain ready to upload.",
      "categories": [
        "interactions"
      ],
      "documentation": {
        "useCases": [],
        "features": [],
        "states": [],
        "props": [
          {
            "name": "onUpload",
            "type": "DropZoneMembraneUpload",
            "description": "Optional. Uploads one file: report progress, honour the signal, resolve when done or throw to show an error with Retry."
          },
          {
            "name": "autoUpload",
            "type": "boolean",
            "description": "Optional. Start uploads on selection. False exposes an Upload action on each ready file."
          },
          {
            "name": "onFilesChange",
            "type": "(files: File[]) => void",
            "description": "Optional. The files currently in the list, after every add and remove."
          },
          {
            "name": "accept",
            "type": "string",
            "description": "Optional. Like an input's accept: MIME types, wildcards and extensions, e.g. \"image/*,.pdf\"."
          },
          {
            "name": "maxSize",
            "type": "number",
            "description": "Optional. Largest file in bytes."
          },
          {
            "name": "maxFiles",
            "type": "number",
            "description": "Optional. Maximum number of files accepted in the list."
          },
          {
            "name": "multiple",
            "type": "boolean",
            "description": "Optional. One file at a time: a new drop replaces it."
          },
          {
            "name": "title",
            "type": "string",
            "description": "Optional. The resting label."
          },
          {
            "name": "hint",
            "type": "string",
            "description": "Optional. File requirements in the tray footer, e.g. \"PNG, JPG or PDF, up to 10 MB\"."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "description": "Optional. Disable user interaction."
          },
          {
            "name": "ref",
            "type": "React.Ref<DropZoneMembraneHandle>",
            "description": "Optional. Ref exposing the file-picker controls."
          }
        ]
      }
    }
  },
  "type": "registry:component"
}