{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"drop-zone-membrane","type":"registry:component","title":"Drop zone · Membrane","description":"A drop zone whose border is a membrane: it reaches for the file, holds it, swallows it and passes it through to its row.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json","https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/drop-zone-membrane.tsx","type":"registry:component","content":"// Hairline UI · drop-zone-membrane · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'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; each file leaves as a chip, drags the line\n *             with it until it slips off, and lands as its row\n *   upload    the row's own hairline fills with progress, the\n *             percent rolls, a ring closes and draws a check\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, CircleAlert, File as FileIcon, FileImage, FileText, RotateCcw, X } from 'lucide-react';\nimport { NumberRoll } from './number-roll';\nimport { TextMorph } from './text-morph';\nimport { EASE_OUT, INTERACTION_FOCUS, interactionIcon, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { useMembrane } from './use-membrane';\n\nexport type DropZoneMembraneStatus = 'uploading' | 'done' | 'error';\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 = (file: File, context: { progress: (fraction: number) => void; signal: AbortSignal }) => 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<React.HTMLAttributes<HTMLDivElement>, 'onDrop' | 'title'> {\n  /** Uploads one file: report progress, honour the signal, resolve when done or throw to show an error with Retry. */\n  onUpload?: DropZoneMembraneUpload;\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  /** Under the label, 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 & { landed: boolean; closing: boolean };\ntype Flight = { id: string; name: string; type: string; x: number; y: number; delay: number };\ntype Drag = { count: number; ok: boolean; held: boolean };\n\nconst RADIUS = 18;\nconst HOLD = 16; // a file this close outside the zone is already held by the bulge, and drops into it\nconst FLIGHT = 640;\n\nconst matchRule = (rule: string, type: string, name: string) =>\n  rule.startsWith('.') ? name.endsWith(rule) : rule.endsWith('/*') ? type.startsWith(rule.slice(0, -1)) : type === rule;\nconst rulesOf = (accept?: string) => (accept ?? '').split(',').map((r) => r.trim().toLowerCase()).filter(Boolean);\nconst EXTENSIONS: Record<string, string> = {\n  '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',\n  '.avif': 'image/avif', '.svg': 'image/svg+xml', '.txt': 'text/plain', '.csv': 'text/csv', '.md': 'text/markdown', '.json': 'application/json',\n  '.zip': 'application/zip', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', '.mp3': 'audio/mpeg', '.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) => (r.startsWith('.') ? !EXTENSIONS[r] || EXTENSIONS[r] === t : matchRule(r, t, '')));\n};\nconst accepts = (rules: string[], file: File) => !rules.length || rules.some((r) => matchRule(r, file.type.toLowerCase(), file.name.toLowerCase()));\n\nexport const formatBytes = (n: number) =>\n  n < 1000 ? `${n} B` : n < 1e6 ? `${Math.round(n / 1000)} KB` : `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)} MB`;\n\nconst onlyLabel = (hint?: string) => (hint ? `Only ${/^.[a-z]/.test(hint) ? hint[0].toLowerCase() + hint.slice(1) : hint}` : undefined);\nconst iconFor = (type: string) => (type.startsWith('image/') ? FileImage : type === 'application/pdf' || type.startsWith('text/') ? FileText : FileIcon);\nconst ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);\nlet counter = 0;\n\nexport function DropZoneMembrane({\n  onUpload,\n  onFilesChange,\n  accept,\n  maxSize,\n  maxFiles,\n  multiple = true,\n  title = 'Drop files here',\n  hint,\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<{ text: string; error: boolean } | null>(null);\n  const [rows, setRows] = React.useState<Row[]>([]);\n  const [flights, setFlights] = React.useState<Flight[]>([]);\n  const [said, setSaid] = React.useState('');\n  const held = React.useRef(false);\n  const rowsRef = React.useRef(rows);\n  rowsRef.current = rows;\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(() => { timers.current.delete(t); fn(); }, ms);\n    timers.current.add(t);\n  }, []);\n  React.useEffect(() => () => {\n    timers.current.forEach(clearTimeout);\n    if (noticeTimer.current) clearTimeout(noticeTimer.current);\n    controllers.current.forEach((c) => c.abort());\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(() => setNotice(null), error ? 2800 : 1800);\n  }, []);\n\n  const local = (clientX: number, clientY: number) => {\n    const r = zone.current?.getBoundingClientRect();\n    return r ? { x: clientX - r.left, y: clientY - r.top, w: r.width, h: r.height } : { 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((clientX: number, clientY: number, types: string[]) => {\n    if (disabled) return false;\n    const p = local(clientX, clientY);\n    const outside = Math.hypot(Math.max(-p.x, 0, p.x - p.w), Math.max(-p.y, 0, p.y - p.h));\n    const ok = !types.length || types.some((t) => mightAccept(rules, t));\n    const isHeld = outside < HOLD;\n    held.current = isHeld;\n    membrane.set('pointer', { x: p.x, y: p.y, kind: ok ? 'reach' : 'recoil' });\n    membrane.swell(isHeld ? (ok ? 3 : -2) : 1.5);\n    setDrag((d) => (d && d.count === types.length && d.ok === ok && d.held === isHeld ? d : { count: types.length, ok, held: isHeld }));\n    return isHeld;\n  }, [disabled, rules, membrane]);\n\n  const release = React.useCallback(() => {\n    held.current = false;\n    membrane.set('pointer', null);\n    membrane.swell(0);\n    setDrag(null);\n  }, [membrane]);\n\n  const start = React.useCallback((id: string, file: File) => {\n    const run = upload.current;\n    const patch = (next: Partial<Row>) => setRows((list) => list.map((r) => (r.id === id ? { ...r, ...next } : r)));\n    if (!run) return patch({ status: 'done', progress: 1 });\n    const controller = new AbortController();\n    controllers.current.set(id, controller);\n    patch({ status: 'uploading', progress: 0, error: undefined });\n    let last = 0;\n    const progress = (f: number) => {\n      const v = Math.max(0, Math.min(1, Number.isFinite(f) ? f : 0));\n      if (controller.signal.aborted || Math.abs(v - last) < 0.005) return;\n      last = v;\n      patch({ progress: v });\n    };\n    Promise.resolve()\n      .then(() => run(file, { progress, signal: controller.signal }))\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 = error instanceof Error && error.message ? error.message : 'Upload failed';\n          patch({ status: 'error', error: message });\n          setSaid(`${file.name} failed to upload. ${message}`);\n        },\n      )\n      .finally(() => controllers.current.delete(id));\n  }, []);\n\n  const remove = React.useCallback((id: string) => {\n    controllers.current.get(id)?.abort();\n    controllers.current.delete(id);\n    setRows((list) => list.map((r) => (r.id === id ? { ...r, closing: true } : r)));\n    later(() => setRows((list) => list.filter((r) => r.id !== id)), reduced ? 0 : 420);\n  }, [later, reduced]);\n\n  // Files arrive at a point in the zone's coordinates: the accepted ones leave as chips toward their rows.\n  const take = React.useCallback((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(onlyLabel(hint) ?? `${file.name} isn't accepted`);\n      else if (maxSize !== undefined && file.size > maxSize) refused.push(`${file.name} is over ${formatBytes(maxSize)}`);\n      else if (accepted.length >= room) refused.push(`Up to ${maxFiles} files`);\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 zr = zone.current?.getBoundingClientRect(), rr = root.current?.getBoundingClientRect();\n    const ox = zr && rr ? zr.left - rr.left : 0, oy = zr && rr ? zr.top - rr.top : 0;\n    const added: Row[] = accepted.map((file) => ({ id: `f${++counter}`, file, status: 'uploading', progress: 0, landed: reduced, closing: false }));\n    setRows((list) => [...list, ...added]);\n    if (!reduced) setFlights((list) => [...list, ...added.map((r, i) => ({ id: r.id, name: r.file.name, type: r.file.type, x: at.x + ox, y: at.y + oy, delay: i * 80 }))]);\n    added.forEach((r) => start(r.id, r.file));\n    const summary = accepted.length === 1 ? `Added ${accepted[0].name}` : `Added ${accepted.length} files`;\n    tell(refused.length ? (refused.length === 1 ? refused[0] : `${refused.length} files skipped`) : summary, refused.length > 0);\n    setSaid([summary, ...refused].join('. '));\n  }, [disabled, multiple, maxFiles, maxSize, rules, hint, membrane, tell, remove, start, reduced]);\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) return;\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).filter((i) => i.kind === 'file').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 (!e.relatedTarget && (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight)) end();\n    };\n    const drop = (e: DragEvent) => {\n      const files = Array.from(e.dataTransfer?.files ?? []);\n      if (held.current && files.length) {\n        e.preventDefault();\n        const p = local(e.clientX, e.clientY);\n        take(files, { x: Math.max(0, Math.min(p.w, p.x)), y: Math.max(0, Math.min(p.h, p.y)) });\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    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    };\n  }, [disabled, track, release, take]);\n\n  React.useImperativeHandle(ref, () => ({\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: () => input.current?.click(),\n  }), [track, release, take]);\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) { first.current = false; return; }\n    report.current?.(rowsRef.current.filter((r) => !r.closing).map((r) => r.file));\n  }, [liveKey]);\n\n  const land = React.useCallback((id: string) => {\n    setRows((list) => list.map((r) => (r.id === id ? { ...r, landed: true } : r)));\n    later(() => setFlights((list) => list.filter((f) => f.id !== id)), 200);\n  }, [later]);\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 ? `Release to add ${drag.count}` : 'Release to add'\n        : drag.count > 1 ? `Drop ${drag.count} files here` : 'Drop it here'\n    : notice?.text ?? title;\n  const icon: 'arrow' | 'refuse' | 'added' = refusing ? 'refuse' : notice && !notice.error ? 'added' : 'arrow';\n  const tone = refusing ? 'refuse' : drag?.held ? 'over' : drag ? 'armed' : 'rest';\n  const colors = {\n    rest: { stroke: 'var(--border)', fill: 'color-mix(in oklab, var(--muted) 45%, transparent)' },\n    armed: { stroke: 'color-mix(in oklab, var(--primary) 55%, var(--border))', fill: 'color-mix(in oklab, var(--primary) 3%, var(--background))' },\n    over: { stroke: 'var(--primary)', fill: 'color-mix(in oklab, var(--primary) 7%, var(--background))' },\n    refuse: { stroke: 'var(--destructive, #dc2626)', fill: 'color-mix(in oklab, var(--destructive, #dc2626) 5%, var(--background))' },\n  }[tone];\n\n  return (\n    <div ref={root} className={`@container relative w-full text-foreground ${className}`} {...props}>\n      <button\n        ref={(el) => { zone.current = el; membrane.attach(el); }}\n        type=\"button\"\n        disabled={disabled}\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={`relative flex h-[152px] w-full cursor-pointer flex-col items-center justify-center gap-3 rounded-[18px] px-5 text-center disabled:cursor-default disabled:opacity-55 @md:h-[176px] ${INTERACTION_FOCUS}`}\n      >\n        <svg aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 size-full overflow-visible\">\n          <path ref={membrane.path} strokeWidth={1} style={{ ...colors, transition: reduced ? 'none' : `stroke 280ms ${EASE_OUT}, fill 360ms ${EASE_OUT}` }} />\n        </svg>\n        <span\n          aria-hidden=\"true\"\n          className=\"relative flex size-9 items-center justify-center rounded-full border bg-background transition-colors duration-300\"\n          style={{ borderColor: tone === 'rest' ? 'var(--border)' : colors.stroke, color: tone === 'refuse' ? colors.stroke : tone === 'rest' ? 'var(--muted-foreground)' : 'var(--primary)' }}\n        >\n          <span ref={membrane.arrow} className=\"absolute inset-0 flex items-center justify-center will-change-transform\" style={interactionIcon(icon === 'arrow', reduced)}>\n            <ArrowUp className=\"size-4\" strokeWidth={1.75} />\n          </span>\n          <span className=\"absolute inset-0 flex items-center justify-center\" style={interactionIcon(icon === 'refuse', reduced)}>\n            <X className=\"size-4\" strokeWidth={1.75} />\n          </span>\n          <span className=\"absolute inset-0 flex items-center justify-center\" style={interactionIcon(icon === 'added', reduced)}>\n            <DrawnCheck on={icon === 'added'} reduced={reduced} />\n          </span>\n        </span>\n        <span className=\"relative flex flex-col items-center gap-1\">\n          <span className={`whitespace-nowrap text-[13.5px] font-medium tracking-[-0.01em] transition-colors duration-300 ${refusing ? 'text-red-600 dark:text-red-400' : ''}`}>\n            <TextMorph>{label}</TextMorph>\n          </span>\n          {hint && (\n            <span id={hintId} className=\"text-balance text-[12px] text-muted-foreground\">\n              <span className=\"whitespace-nowrap\"><span className=\"text-foreground underline decoration-border underline-offset-[3px]\">Browse</span> or paste ·</span> {hint}\n            </span>\n          )}\n        </span>\n      </button>\n      <input\n        ref={input}\n        type=\"file\"\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\">\n          {rows.map((r) => (\n            <FileRow key={r.id} row={r} reduced={reduced} onRemove={() => remove(r.id)} onRetry={() => start(r.id, r.file)} />\n          ))}\n        </ul>\n      )}\n\n      {flights.map((f) => (\n        <Chip key={f.id} flight={f} root={root} zone={zone} membrane={membrane} onLand={land} landed={rows.find((r) => r.id === f.id)?.landed ?? true} />\n      ))}\n\n      <p role=\"status\" className=\"sr-only\">{said}</p>\n    </div>\n  );\n}\n\n/** A file in flight: from where it was dropped, down through the border, onto its row. */\nfunction Chip({ flight, root, zone, membrane, onLand, landed }: {\n  flight: Flight;\n  root: React.RefObject<HTMLDivElement | null>;\n  zone: React.RefObject<HTMLButtonElement | null>;\n  membrane: ReturnType<typeof useMembrane>;\n  onLand: (id: string) => void;\n  landed: boolean;\n}) {\n  const el = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const chip = el.current, base = root.current;\n    if (!chip || !base) return;\n    const w = chip.offsetWidth, h = chip.offsetHeight;\n    const begin = performance.now() + flight.delay;\n    let frame = 0;\n    const step = (now: number) => {\n      const t = Math.max(0, Math.min(1, (now - begin) / FLIGHT));\n      const lead = base.querySelector<HTMLElement>(`[data-file=\"${flight.id}\"] [data-lead]`);\n      const br = base.getBoundingClientRect();\n      const lr = lead?.getBoundingClientRect();\n      const tx = lr ? lr.left - br.left - 10 : flight.x - w / 2;\n      const ty = lr ? lr.top - br.top + lr.height / 2 - h / 2 : flight.y - h / 2;\n      const sx = flight.x - w / 2, sy = flight.y - h / 2;\n      const e = ease(t);\n      const x = sx + (tx - sx) * e, y = sy + (ty - sy) * e;\n      chip.style.transform = `translate3d(${x}px,${y}px,0)`;\n      const zr = zone.current?.getBoundingClientRect();\n      if (zr) membrane.set(flight.id, { x: x + w / 2 - (zr.left - br.left), y: y + h / 2 - (zr.top - br.top), kind: 'carry', spread: w / 3 });\n      if (t < 1) frame = requestAnimationFrame(step);\n      else {\n        membrane.set(flight.id, null);\n        onLand(flight.id);\n      }\n    };\n    chip.style.transform = `translate3d(${flight.x - w / 2}px,${flight.y - h / 2}px,0)`;\n    chip.animate([{ opacity: 0, scale: '0.9' }, { opacity: 1, scale: '1' }], { duration: 180, easing: EASE_OUT });\n    frame = requestAnimationFrame(step);\n    return () => {\n      cancelAnimationFrame(frame);\n      membrane.set(flight.id, null);\n    };\n    // One flight per chip.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  const Icon = iconFor(flight.type);\n  return (\n    <div\n      ref={el}\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute left-0 top-0 z-10 flex h-8 max-w-[220px] items-center gap-2.5 rounded-full px-2.5 text-[13px] will-change-transform\"\n      style={{\n        opacity: landed ? 0 : 1,\n        background: landed ? 'transparent' : 'var(--background)',\n        boxShadow: landed ? 'inset 0 0 0 1px transparent' : 'inset 0 0 0 1px var(--border)',\n        transition: `opacity 200ms ${EASE_OUT}, background-color 160ms ${EASE_OUT}, box-shadow 160ms ${EASE_OUT}`,\n      }}\n    >\n      <Icon className=\"size-4 shrink-0 text-muted-foreground\" strokeWidth={1.75} />\n      <span className=\"truncate\">{flight.name}</span>\n    </div>\n  );\n}\n\nfunction FileRow({ row, reduced, onRemove, onRetry }: { row: Row; reduced: boolean; onRemove: () => void; onRetry: () => void }) {\n  const [open, setOpen] = React.useState(reduced);\n  React.useEffect(() => {\n    if (open) return;\n    let b = 0;\n    const a = requestAnimationFrame(() => { b = requestAnimationFrame(() => setOpen(true)); });\n    return () => { cancelAnimationFrame(a); cancelAnimationFrame(b); };\n  }, [open]);\n  const shown = open && !row.closing;\n  const Icon = iconFor(row.file.type);\n  const pct = Math.round(row.progress * 100);\n  const { status } = row;\n  const swap = reduced ? 'none' : `opacity 200ms ${EASE_OUT}`;\n  return (\n    <li\n      data-file={row.id}\n      className=\"grid\"\n      style={{\n        gridTemplateRows: shown ? '1fr' : '0fr',\n        opacity: shown ? 1 : 0,\n        transition: reduced ? 'none' : `grid-template-rows 420ms ${EASE_OUT}, opacity ${row.closing ? 180 : 320}ms ${EASE_OUT}`,\n      }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <div className=\"relative flex h-11 items-center gap-3\">\n          <span data-lead className=\"flex min-w-0 items-center gap-2.5 text-[13px]\" style={{ opacity: row.landed ? 1 : 0, transition: swap }}>\n            <Icon className=\"size-4 shrink-0 text-muted-foreground\" strokeWidth={1.75} aria-hidden=\"true\" />\n            <span className=\"truncate\">{row.file.name}</span>\n          </span>\n          <span className=\"ml-auto flex shrink-0 items-center gap-2 text-[12px] tabular-nums text-muted-foreground\" style={{ opacity: row.landed ? 1 : 0, transition: swap }}>\n            <span className={`whitespace-nowrap ${status === 'error' ? 'text-red-600 dark:text-red-400' : ''}`}>\n              {status === 'error' ? (\n                <TextMorph>Failed</TextMorph>\n              ) : (\n                <>\n                  {formatBytes(row.file.size)}\n                  <span className=\"inline-grid transition-[grid-template-columns,opacity] duration-300\" style={{ gridTemplateColumns: status === 'uploading' ? '1fr' : '0fr', opacity: status === 'uploading' ? 1 : 0 }}>\n                    <span className=\"min-w-0 overflow-hidden whitespace-nowrap [clip-path:inset(-4px_-2px)]\">\n                      <span className=\"px-1\">·</span>\n                      <NumberRoll value={pct} suffix=\"%\" duration={600} />\n                    </span>\n                  </span>\n                </>\n              )}\n            </span>\n            <StatusMark status={status} progress={row.progress} reduced={reduced} />\n            {status === 'error' && (\n              <button type=\"button\" onClick={onRetry} aria-label={`Retry ${row.file.name}`} className={`flex size-6 cursor-pointer items-center justify-center rounded-full text-foreground hover:bg-muted ${INTERACTION_FOCUS}`}>\n                <RotateCcw className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n              </button>\n            )}\n            <button type=\"button\" onClick={onRemove} aria-label={status === 'uploading' ? `Cancel ${row.file.name}` : `Remove ${row.file.name}`} className={`flex size-6 cursor-pointer items-center justify-center rounded-full hover:bg-muted hover:text-foreground ${INTERACTION_FOCUS}`}>\n              <X className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n            </button>\n          </span>\n          {/* The row's own hairline is its progress bar. */}\n          <span aria-hidden=\"true\" className=\"absolute inset-x-0 bottom-0 h-px bg-border\" />\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute inset-x-0 bottom-0 h-px origin-left\"\n            style={{\n              background: status === 'error' ? 'var(--destructive, #dc2626)' : 'var(--primary)',\n              transform: `scaleX(${status === 'error' ? 1 : row.progress})`,\n              opacity: status === 'done' ? 0 : row.landed ? 1 : 0,\n              transition: reduced ? 'none' : `transform 520ms ${EASE_OUT}, opacity ${status === 'done' ? 700 : 200}ms ${EASE_OUT} ${status === 'done' ? 300 : 0}ms, background-color 200ms`,\n            }}\n          />\n        </div>\n      </div>\n    </li>\n  );\n}\n\n/** A ring that closes with progress, then gives way to a check that draws itself; an alert for errors. */\nfunction StatusMark({ status, progress, reduced }: { status: DropZoneMembraneStatus; progress: number; reduced: boolean }) {\n  return (\n    <span aria-hidden=\"true\" className=\"relative size-4 shrink-0\">\n      <svg viewBox=\"0 0 16 16\" className=\"absolute inset-0 size-4 -rotate-90\" style={interactionIcon(status === 'uploading', reduced)}>\n        <circle cx=\"8\" cy=\"8\" r=\"6\" fill=\"none\" stroke=\"var(--border)\" strokeWidth=\"1.5\" />\n        <circle\n          cx=\"8\" cy=\"8\" r=\"6\" fill=\"none\" stroke=\"var(--primary)\" strokeWidth=\"1.5\" strokeLinecap=\"round\" pathLength={1}\n          strokeDasharray=\"1\" strokeDashoffset={1 - progress}\n          style={{ transition: reduced ? 'none' : `stroke-dashoffset 520ms ${EASE_OUT}` }}\n        />\n      </svg>\n      <span className=\"absolute inset-0 flex items-center justify-center text-primary\" style={interactionIcon(status === 'done', reduced)}>\n        <DrawnCheck on={status === 'done'} reduced={reduced} />\n      </span>\n      <span className=\"absolute inset-0 flex items-center justify-center text-red-600 dark:text-red-400\" style={interactionIcon(status === 'error', reduced)}>\n        <CircleAlert className=\"size-4\" strokeWidth={1.75} />\n      </span>\n    </span>\n  );\n}\n\nfunction DrawnCheck({ on, reduced }: { on: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path\n        d=\"M3.5 8.5l3 3 6-7\"\n        pathLength={1}\n        strokeDasharray=\"1\"\n        strokeDashoffset={on ? 0 : 1}\n        style={{ transition: reduced ? 'none' : `stroke-dashoffset ${on ? 420 : 0}ms ${EASE_OUT} ${on ? 120 : 0}ms` }}\n      />\n    </svg>\n  );\n}\n"},{"path":"registry/use-membrane.ts","type":"registry:component","content":"// Hairline UI · drop-zone-membrane · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'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 = { x: number; y: number; kind: 'reach' | 'recoil' | 'carry'; spread?: number };\n\ntype Ring = {\n  count: number; w: number; h: number; r: number;\n  bx: Float32Array; by: Float32Array; nx: Float32Array; ny: Float32Array;\n  u: Float32Array; v: Float32Array; goal: Float32Array; 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, y0 = 0.5, x1 = w - 0.5, 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, y0 = 0.5, x1 = w - 0.5, 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, sh = y1 - y0 - 2 * r, 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, w, h, r,\n    bx: new Float32Array(count), by: new Float32Array(count), nx: new Float32Array(count), ny: new Float32Array(count),\n    u: new Float32Array(count), v: new Float32Array(count), goal: new Float32Array(count), acc: new Float32Array(count),\n  };\n  // Edges and corners clockwise from the top-left end of the top edge.\n  const parts: { len: number; at: (t: number) => [number, number, number, number] }[] = [\n    { len: sw, at: (t) => [x0 + r + t * sw, y0, 0, -1] },\n    { len: arc, at: (t) => corner(x1 - r, y0 + r, -Math.PI / 2 + (t * Math.PI) / 2) },\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    { len: arc, at: (t) => corner(x0 + r, y1 - r, Math.PI / 2 + (t * Math.PI) / 2) },\n    { len: sh, at: (t) => [x0, y1 - r - t * sh, -1, 0] },\n    { len: arc, at: (t) => corner(x0 + r, y0 + r, Math.PI + (t * Math.PI) / 2) },\n  ];\n  function corner(cx: number, cy: number, a: number): [number, number, number, number] {\n    const c = Math.cos(a), 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(parts[k].len ? Math.min(1, s / parts[k].len) : 0);\n    ring.bx[i] = x; ring.by[i] = y; ring.nx[i] = nx; ring.ny[i] = ny;\n  }\n  return ring;\n}\n\nexport function useMembrane({ radius, reduced }: { radius: number; reduced: boolean }) {\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(new Map<string, MembraneForce & { inside: boolean }>());\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((rest: boolean) => {\n    const g = ring.current, 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  }, [radius]);\n\n  const tick = React.useCallback((now: number) => {\n    frame.current = 0;\n    const g = ring.current;\n    if (!g) return;\n    const dt = last.current ? Math.min(0.05, (now - last.current) / 1000) : 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, two = 2 * sigma * sigma, 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], 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') want = inside ? 10 * Math.exp(s / 28) : s < REACH ? PULL * (1 - Math.max(0, s) / REACH) ** 2 : 0;\n        else if (f.kind === 'recoil') want = inside ? -7 * Math.exp(s / 28) : s < REACH ? -12 * (1 - Math.max(0, s) / REACH) ** 2 : 0;\n        else want = inside ? 18 * Math.exp(s / 22) : s < SLIP ? (s + 6) * 1.6 : 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], 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, r = i === n - 1 ? 0 : i + 1;\n        acc[i] = SPRING * (goal[i] - u[i]) + TENSION * (u[l] + u[r] - 2 * u[i]) - DRAG * v[i] + 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, 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) 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); 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, tr = turn.current;\n    let angle = 0, lean = 0;\n    if (pointer) {\n      const cx = w / 2, cy = h / 2, dx = pointer.x - cx, dy = pointer.y - cy;\n      angle = pointer.inside ? (pointer.kind === 'reach' ? 180 : 0) : (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; 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) 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  }, [draw]);\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, h = el.offsetHeight;\n      if (!w || !h || (ring.current && ring.current.w === w && ring.current.h === h)) 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    // Reduced motion: no bending. Settle everything where it is.\n    forces.current.clear();\n    swell.current = 0;\n    if (ring.current) { ring.current.u.fill(0); ring.current.v.fill(0); }\n    draw(true);\n    if (arrow.current) arrow.current.style.transform = '';\n  }, [reduced, draw]);\n\n  const impulse = React.useCallback((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], dy = y - g.by[i];\n      g.v[i] += base + peak * Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));\n    }\n    kick();\n  }, [kick]);\n\n  return React.useMemo(() => ({\n    attach, path, 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) forces.current.set(key, { ...force, inside: prev?.inside ?? (force.x > 0 && force.y > 0 && force.x < (ring.current?.w ?? 0) && force.y < (ring.current?.h ?? 0)) });\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) { impulse(x, y, -70, -230, 90); },\n    spit(x: number, y: number) { impulse(x, y, 0, 320, 64); },\n  }), [attach, kick, impulse]);\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · drop-zone-membrane · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'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 = '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 = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\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]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\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(samples: PointerSample[], x: number, time: number) {\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) return 0;\n  const first = samples[0], 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(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/drop-zone-membrane. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}