{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload-seal",
  "title": "File Upload Seal",
  "description": "File upload where progress is the container: a loose dashed outline 24px outside the card pulls taut as the file uploads, clicks shut with a pucker at 100%, and relaxes back out with a wobble on failure.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/file-upload-seal/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type DragEvent,\n  type KeyboardEvent,\n  type RefObject,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// VacuumSeal — a file upload zone where progress IS the container, not a\n// bar appended to it. Dropping a file spawns a loose dashed SVG rounded-rect\n// outline 24px outside the card; as upload progress advances, one\n// critically-damped spring per file pulls that outline inward — inset,\n// dasharray, and a muted->foreground stroke crossfade all riding the same\n// spring parameter — so it visibly breathes toward the card rather than\n// stepping. Arrival at 100% is a real end-state: status flips to \"sealed\"\n// and the card plays a one-shot scale(0.99) pucker-and-release. A failed\n// upload targets the spring back to its loose, 24px-out rest position with\n// light underdamping, so it visibly overshoots and wobbles before settling\n// — the same spring engine, just less damped, drawn in --error instead of\n// muted/foreground. Direct-DOM per-card rAF loops, one per uploading/sealing\n// file, sleep once each has settled; pre-seeded (defaultFiles) entries never\n// run the spring at all — their geometry is written once, synchronously,\n// before paint.\n// ---------------------------------------------------------------------------\n\nconst OUTER_GAP = 24; // px — loose outline distance at progress 0\nconst WOBBLE = 6; // px — extra overshoot room for the fail-relax bounce\nconst PAD = OUTER_GAP + WOBBLE + 4; // svg overhang each side\nconst CARD_RADIUS = 12; // matches the card's own rounded-md\nconst SEAL_K = 90; // spring constant while sealing/uploading (critically damped)\nconst FAIL_K = 46; // spring constant while relaxing after a failure (underdamped)\nconst FAIL_ZETA = 0.4;\nconst PUCKER_MS = 260;\n\ntype UploadStatus = \"uploading\" | \"sealed\" | \"failed\";\n\nexport interface VacuumSealFile {\n  id: string;\n  name: string;\n  size: number;\n  type: string;\n}\n\ninterface FileEntry extends VacuumSealFile {\n  status: UploadStatus;\n  progress: number; // 0..1\n}\n\nfunction formatSize(bytes: number): string {\n  if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n  if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;\n  return `${bytes} B`;\n}\n\nfunction acceptSummary(accept: string[]): string {\n  if (accept.length === 0) return \"Any file type\";\n  return accept\n    .map((a) => (a.startsWith(\".\") ? a.slice(1).toUpperCase() : a))\n    .join(\", \");\n}\n\nfunction statusText(f: FileEntry): string {\n  const size = formatSize(f.size);\n  if (f.status === \"sealed\") return `${size} · sealed`;\n  if (f.status === \"failed\") return `upload failed · ${size}`;\n  return `uploading ${size}… ${Math.round(f.progress * 100)}%`;\n}\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.max(lo, Math.min(hi, v));\n}\n\n// ---------------------------------------------------------------------------\n// SealOutline — the per-card spring engine. Drives an SVG rect (or two, for\n// the muted/foreground crossfade) via direct attribute writes, never React\n// state. `dp` is a generalized \"seal progress\" spring variable: 0 = loose\n// outline 24px out, 1 = flush/taut. For status \"failed\" the spring target is\n// 0 with light underdamping, so dp is allowed to swing slightly negative —\n// that alone produces the relax-with-wobble, no separate keyframe needed.\n// ---------------------------------------------------------------------------\nfunction useSealOutline(\n  wrapRef: RefObject<HTMLDivElement | null>,\n  svgRef: RefObject<SVGSVGElement | null>,\n  mutedRef: RefObject<SVGRectElement | null>,\n  fgRef: RefObject<SVGRectElement | null>,\n  errRef: RefObject<SVGRectElement | null>,\n  status: UploadStatus,\n  progress: number,\n  reduced: boolean,\n  frozen: boolean // pre-seeded entries: write geometry once, never spring\n) {\n  const dpRef = useRef(status === \"sealed\" ? 1 : status === \"failed\" ? 0 : progress);\n  const vRef = useRef(0);\n  const wRef = useRef(0);\n  const hRef = useRef(0);\n  const rafRef = useRef(0);\n  const lastRef = useRef(0);\n\n  const applyGeom = useCallback(() => {\n    const w = wRef.current;\n    const h = hRef.current;\n    if (w < 1 || h < 1) return;\n    const dp = dpRef.current;\n    const offset = clamp(OUTER_GAP * (1 - dp), 0, OUTER_GAP + WOBBLE);\n    const rx = CARD_RADIUS + offset * 0.55;\n    const x = PAD - offset;\n    const y = PAD - offset;\n    const rw = Math.max(0, w + offset * 2);\n    const rh = Math.max(0, h + offset * 2);\n    const svg = svgRef.current;\n    if (svg) {\n      svg.setAttribute(\"width\", String(w + PAD * 2));\n      svg.setAttribute(\"height\", String(h + PAD * 2));\n    }\n    const pC = clamp(dp, 0, 1);\n    const dashLen = (8 - 7 * pC).toFixed(2);\n    const gapLen = (4 - 4 * pC).toFixed(2);\n    for (const r of [mutedRef.current, fgRef.current, errRef.current]) {\n      if (!r) continue;\n      r.setAttribute(\"x\", x.toFixed(2));\n      r.setAttribute(\"y\", y.toFixed(2));\n      r.setAttribute(\"width\", rw.toFixed(2));\n      r.setAttribute(\"height\", rh.toFixed(2));\n      r.setAttribute(\"rx\", rx.toFixed(2));\n    }\n    if (mutedRef.current) {\n      mutedRef.current.setAttribute(\"stroke-dasharray\", `${dashLen} ${gapLen}`);\n      mutedRef.current.style.opacity = String(1 - pC);\n    }\n    if (fgRef.current) {\n      fgRef.current.setAttribute(\"stroke-dasharray\", `${dashLen} ${gapLen}`);\n      fgRef.current.style.opacity = String(pC);\n    }\n    // errRef keeps its fixed loose dasharray (set in markup) — only geometry moves\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // measure + write geometry synchronously before first paint — this is what\n  // keeps a pre-seeded (frozen) card static at rest instead of springing in\n  useLayoutEffect(() => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const measure = () => {\n      const rect = wrap.getBoundingClientRect();\n      wRef.current = rect.width;\n      hRef.current = rect.height;\n      applyGeom();\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(wrap);\n    return () => ro.disconnect();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  useEffect(() => {\n    if (frozen) return; // pre-seeded: geometry already written once, above\n    if (reduced) {\n      // discrete quartile steps, no spring, no overshoot\n      const target = status === \"failed\" ? 0 : status === \"sealed\" ? 1 : progress;\n      dpRef.current = status === \"failed\" ? 0 : Math.floor(clamp(target, 0, 1) * 4) / 4;\n      vRef.current = 0;\n      applyGeom();\n      return;\n    }\n    const target = status === \"failed\" ? 0 : status === \"sealed\" ? 1 : progress;\n    const k = status === \"failed\" ? FAIL_K : SEAL_K;\n    const zeta = status === \"failed\" ? FAIL_ZETA : 1;\n    const c = 2 * zeta * Math.sqrt(k);\n\n    const loop = (now: number) => {\n      const dt = lastRef.current ? Math.min(0.05, (now - lastRef.current) / 1000) : 1 / 60;\n      lastRef.current = now;\n      const dp = dpRef.current;\n      const err = target - dp;\n      vRef.current += (k * err - c * vRef.current) * dt;\n      dpRef.current = dp + vRef.current * dt;\n      applyGeom();\n      if (Math.abs(target - dpRef.current) < 0.0025 && Math.abs(vRef.current) < 0.01) {\n        dpRef.current = target;\n        vRef.current = 0;\n        applyGeom();\n        rafRef.current = 0;\n        lastRef.current = 0;\n        return;\n      }\n      rafRef.current = requestAnimationFrame(loop);\n    };\n    lastRef.current = 0;\n    cancelAnimationFrame(rafRef.current);\n    rafRef.current = requestAnimationFrame(loop);\n    return () => cancelAnimationFrame(rafRef.current);\n  }, [status, progress, reduced, frozen, applyGeom]);\n}\n\nfunction FileCard({\n  file,\n  reduced,\n  frozen,\n}: {\n  file: FileEntry;\n  reduced: boolean;\n  frozen: boolean;\n}) {\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const svgRef = useRef<SVGSVGElement>(null);\n  const mutedRef = useRef<SVGRectElement>(null);\n  const fgRef = useRef<SVGRectElement>(null);\n  const errRef = useRef<SVGRectElement>(null);\n  const prevStatusRef = useRef(file.status);\n  const [justSealed, setJustSealed] = useState(false);\n  const progId = useId();\n\n  useSealOutline(\n    wrapRef,\n    svgRef,\n    mutedRef,\n    fgRef,\n    errRef,\n    file.status,\n    file.progress,\n    reduced,\n    frozen\n  );\n\n  useEffect(() => {\n    if (!frozen && !reduced && prevStatusRef.current !== \"sealed\" && file.status === \"sealed\") {\n      setJustSealed(true);\n    }\n    prevStatusRef.current = file.status;\n  }, [file.status, frozen, reduced]);\n\n  const pct = Math.round((file.status === \"sealed\" ? 1 : file.progress) * 100);\n\n  return (\n    <li className=\"relative\" style={{ margin: `${PAD - 10}px 0` }}>\n      <div\n        ref={wrapRef}\n        data-just-sealed={justSealed || undefined}\n        onAnimationEnd={() => setJustSealed(false)}\n        className=\"ns-file-upload-seal-card relative rounded-md border border-border bg-surface\"\n      >\n        <svg\n          ref={svgRef}\n          aria-hidden\n          className=\"pointer-events-none absolute overflow-visible\"\n          style={{ left: -PAD, top: -PAD }}\n        >\n          {file.status !== \"failed\" ? (\n            <>\n              <rect\n                ref={mutedRef}\n                className=\"text-ns-muted\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={1.5}\n              />\n              <rect\n                ref={fgRef}\n                className=\"text-foreground\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={1.5}\n              />\n            </>\n          ) : (\n            <rect\n              ref={errRef}\n              fill=\"none\"\n              strokeWidth={1.5}\n              strokeDasharray=\"8 4\"\n              style={{ stroke: \"var(--error, #ea001d)\" }}\n            />\n          )}\n        </svg>\n\n        <div className=\"relative z-[1] flex items-center gap-3 px-4 py-3\">\n          <span className=\"shrink-0 text-ns-muted\" aria-hidden>\n            {file.status === \"sealed\" ? (\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"h-4 w-4 text-foreground\">\n                <path d=\"M4 12.5 9.5 18 20 6\" />\n              </svg>\n            ) : file.status === \"failed\" ? (\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"h-4 w-4\" style={{ stroke: \"var(--error, #ea001d)\" }}>\n                <path d=\"M12 9v4M12 16.5v.01\" />\n                <path d=\"M10.3 3.9 2.6 17a1.8 1.8 0 0 0 1.55 2.7h15.7a1.8 1.8 0 0 0 1.55-2.7L13.7 3.9a1.8 1.8 0 0 0-3.4 0Z\" />\n              </svg>\n            ) : (\n              <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"h-4 w-4\">\n                <path d=\"M14 3v4a1 1 0 0 0 1 1h4\" />\n                <path d=\"M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z\" />\n              </svg>\n            )}\n          </span>\n          <div className=\"min-w-0 flex-1\">\n            <p className=\"truncate text-sm text-foreground\">{file.name}</p>\n            <p\n              id={progId}\n              role=\"progressbar\"\n              aria-label={`${file.name} upload progress`}\n              aria-valuemin={0}\n              aria-valuemax={100}\n              aria-valuenow={pct}\n              aria-valuetext={statusText(file)}\n              className=\"font-mono text-[11px] text-ns-muted\"\n            >\n              {statusText(file)}\n            </p>\n          </div>\n        </div>\n      </div>\n    </li>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// simulated upload — used whenever a consumer does not pass `uploadFile`.\n// Chunky, irregular progress events (not a smooth ramp) are the point: it's\n// what gives the spring something to visibly catch up to.\n// ---------------------------------------------------------------------------\nfunction simulateUpload(\n  onProgress: (p: number) => void,\n  onDone: (ok: boolean) => void\n): () => void {\n  let p = 0;\n  const failAt = Math.random() < 0.16 ? 0.25 + Math.random() * 0.55 : 2; // 2 = never\n  const id = setInterval(\n    () => {\n      p = Math.min(1, p + 0.1 + Math.random() * 0.16);\n      if (p >= failAt) {\n        clearInterval(id);\n        onProgress(Math.min(p, 0.97));\n        onDone(false);\n        return;\n      }\n      onProgress(p);\n      if (p >= 1) {\n        clearInterval(id);\n        onDone(true);\n      }\n    },\n    170 + Math.random() * 110\n  );\n  return () => clearInterval(id);\n}\n\nexport function VacuumSeal({\n  defaultFiles = [],\n  accept = [],\n  maxSizeBytes = 25 * 1024 * 1024,\n  uploadFile,\n  onFilesChange,\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Upload files\",\n}: {\n  /** files rendered already in their final state — sealed, mid-upload, or failed — at mount, with no entrance animation */\n  defaultFiles?: {\n    name: string;\n    size: number;\n    type: string;\n    status?: UploadStatus;\n    progress?: number;\n  }[];\n  /** allowed types: extensions (\".png\") or mime (\"image/png\", \"image/*\"); empty = any */\n  accept?: string[];\n  /** rejects any file larger than this. No default — unlimited. */\n  maxSizeBytes?: number;\n  /** perform the real upload, reporting 0..1 via onProgress; reject to fail the seal. omit to use a built-in simulated upload. */\n  uploadFile?: (file: File, onProgress: (p: number) => void) => Promise<void>;\n  /** called with the full file list after any add, progress update, or removal */\n  onFilesChange?: (files: VacuumSealFile[]) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible name for the dropzone. Default \"Upload files\". */\n  \"aria-label\"?: string;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const zoneRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const idSeq = useRef(0);\n  const dragDepth = useRef(0);\n  const cleanupsRef = useRef(new Map<string, () => void>());\n  const quartileRef = useRef(new Map<string, number>());\n  const frozenIdsRef = useRef(new Set<string>());\n\n  const [files, setFiles] = useState<FileEntry[]>(() =>\n    defaultFiles.map((f, i) => {\n      const id = `vs-d${i}`;\n      frozenIdsRef.current.add(id);\n      return {\n        id,\n        name: f.name,\n        size: f.size,\n        type: f.type,\n        status: f.status ?? \"sealed\",\n        progress: f.progress ?? (f.status === \"failed\" ? 0 : 1),\n      };\n    })\n  );\n  const [isOver, setIsOver] = useState(false);\n  const [reduced, setReduced] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  const hintId = useId();\n  const lastNotifyKeyRef = useRef(\"\");\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduced(mq.matches);\n    const onChange = () => setReduced(mq.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(() => {\n    const cleanups = cleanupsRef.current;\n    return () => {\n      for (const stop of cleanups.values()) stop();\n      cleanups.clear();\n    };\n  }, []);\n\n  const publicFiles = (list: FileEntry[]): VacuumSealFile[] =>\n    list.map(({ id, name, size, type }) => ({ id, name, size, type }));\n\n  // notify from an effect, keyed on the id set (add/remove only, not every\n  // progress tick) — never call a consumer's setState from inside a\n  // setFiles updater, which runs during this component's own render\n  useEffect(() => {\n    const pub = publicFiles(files);\n    const key = pub.map((f) => f.id).join(\",\");\n    if (key !== lastNotifyKeyRef.current) {\n      lastNotifyKeyRef.current = key;\n      onFilesChange?.(pub);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [files]);\n\n  const startUpload = (id: string, name: string, file: File) => {\n    const onProgress = (p: number) => {\n      setFiles((fs) => fs.map((f) => (f.id === id ? { ...f, progress: p } : f)));\n      const bucket = p >= 1 ? 4 : Math.floor(p * 4);\n      const prev = quartileRef.current.get(id) ?? 0;\n      if (bucket > prev && bucket > 0 && bucket < 4) {\n        quartileRef.current.set(id, bucket);\n        setAnnounce(`${name} ${bucket * 25}% uploaded`);\n      }\n    };\n    const finalize = (ok: boolean) => {\n      cleanupsRef.current.delete(id);\n      setFiles((fs) =>\n        fs.map((f) =>\n          f.id === id ? { ...f, status: ok ? \"sealed\" : \"failed\", progress: ok ? 1 : f.progress } : f\n        )\n      );\n      setAnnounce(ok ? `${name} sealed` : `${name} upload failed`);\n    };\n    if (uploadFile) {\n      let live = true;\n      uploadFile(file, (p) => live && onProgress(p)).then(\n        () => live && finalize(true),\n        () => live && finalize(false)\n      );\n      cleanupsRef.current.set(id, () => {\n        live = false;\n      });\n    } else {\n      const stop = simulateUpload(onProgress, finalize);\n      cleanupsRef.current.set(id, stop);\n    }\n  };\n\n  const validate = (file: File): string | null => {\n    if (file.size > maxSizeBytes) return `exceeds the ${formatSize(maxSizeBytes)} limit`;\n    if (accept.length > 0) {\n      const lower = file.name.toLowerCase();\n      const ok = accept.some((a) => {\n        const s = a.trim().toLowerCase();\n        if (s.startsWith(\".\")) return lower.endsWith(s);\n        if (s.endsWith(\"/*\")) return file.type.startsWith(s.slice(0, -1));\n        return file.type === s;\n      });\n      if (!ok) return \"file type not accepted\";\n    }\n    return null;\n  };\n\n  const processFiles = (list: FileList | null) => {\n    if (!list || list.length === 0) return;\n    const accepted: FileEntry[] = [];\n    const messages: string[] = [];\n    for (const file of Array.from(list)) {\n      const reason = validate(file);\n      if (reason !== null) {\n        messages.push(`${file.name} rejected — ${reason}`);\n        continue;\n      }\n      const id = `vs-${idSeq.current++}`;\n      accepted.push({\n        id,\n        name: file.name,\n        size: file.size,\n        type: file.type,\n        status: \"uploading\",\n        progress: 0,\n      });\n      startUpload(id, file.name, file);\n    }\n    if (accepted.length > 0) {\n      setFiles((fs) => [...fs, ...accepted]);\n      messages.push(...accepted.map((f) => `${f.name} added`));\n    }\n    if (messages.length > 0) setAnnounce(messages.join(\". \"));\n  };\n\n  const openPicker = () => inputRef.current?.click();\n\n  const onZoneKey = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      openPicker();\n    }\n  };\n  const dragHasFiles = (e: DragEvent<HTMLDivElement>) =>\n    Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n\n  const onDragEnter = (e: DragEvent<HTMLDivElement>) => {\n    if (!dragHasFiles(e)) return;\n    e.preventDefault();\n    dragDepth.current += 1;\n    if (dragDepth.current === 1) setIsOver(true);\n  };\n  const onDragOver = (e: DragEvent<HTMLDivElement>) => {\n    if (!dragHasFiles(e)) return;\n    e.preventDefault();\n    if (e.dataTransfer) e.dataTransfer.dropEffect = \"copy\";\n  };\n  const onDragLeave = (e: DragEvent<HTMLDivElement>) => {\n    if (!dragHasFiles(e)) return;\n    e.preventDefault();\n    dragDepth.current = Math.max(0, dragDepth.current - 1);\n    if (dragDepth.current === 0) setIsOver(false);\n  };\n  const onDrop = (e: DragEvent<HTMLDivElement>) => {\n    if (!dragHasFiles(e)) return;\n    e.preventDefault();\n    dragDepth.current = 0;\n    setIsOver(false);\n    processFiles(e.dataTransfer?.files ?? null);\n  };\n\n  // -- landing-card autoplay bridge -----------------------------------------\n  // Mirrors file-upload-thermal's pattern: the shared pointer-path driver only\n  // dispatches Pointer/Mouse events, so a sweep across the zone is bridged\n  // into a synthetic drop on its \"leave\" (sweep-closing) edge, via the exact\n  // same processFiles() a real onDrop calls with a File built through the\n  // standard constructible DataTransfer. Every visual — outline appearing,\n  // spring-tightening, pucker, or fail-wobble — is the real engine above;\n  // nothing autoplay-specific is animated. Bounded: the previous demo file\n  // is cleared before the next is added, so a card left running never\n  // accumulates rows. Gated on [data-autoplay-root], stamped only for\n  // embed=1&autoplay=1, so a real hover on /preview/file-upload-seal never wires\n  // this up.\n  useEffect(() => {\n    const zone = zoneRef.current;\n    if (!zone || !zone.closest(\"[data-autoplay-root]\")) return;\n    let dragging = false;\n    let demoId: string | null = null;\n    const onEnter = () => {\n      dragging = true;\n      setIsOver(true);\n    };\n    const onLeave = () => {\n      if (!dragging) return;\n      dragging = false;\n      setIsOver(false);\n      if (demoId) {\n        const closing = demoId;\n        cleanupsRef.current.get(closing)?.();\n        cleanupsRef.current.delete(closing);\n        setFiles((fs) => fs.filter((f) => f.id !== closing));\n      }\n      const dt = new DataTransfer();\n      dt.items.add(new File([\"demo\"], \"quarter-report.pdf\", { type: \"application/pdf\" }));\n      // idSeq is incremented exactly once per accepted file inside\n      // processFiles, synchronously and in order — capturing it just before\n      // the call gives the exact id the single dropped file will receive,\n      // with no need to diff file lists afterward.\n      const nextId = `vs-${idSeq.current}`;\n      processFiles(dt.files);\n      demoId = nextId;\n    };\n    zone.addEventListener(\"pointerenter\", onEnter);\n    zone.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      zone.removeEventListener(\"pointerenter\", onEnter);\n      zone.removeEventListener(\"pointerleave\", onLeave);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const hint = `${acceptSummary(accept)} · max ${formatSize(maxSizeBytes)}`;\n\n  return (\n    <div ref={rootRef} className={`flex flex-col gap-2 ${className}`}>\n      <div\n        ref={zoneRef}\n        role=\"button\"\n        tabIndex={0}\n        aria-label={ariaLabel}\n        aria-describedby={hintId}\n        onClick={openPicker}\n        onKeyDown={onZoneKey}\n        onDragEnter={onDragEnter}\n        onDragOver={onDragOver}\n        onDragLeave={onDragLeave}\n        onDrop={onDrop}\n        className={`relative flex min-h-[7rem] cursor-pointer flex-col items-center justify-center gap-1.5 rounded-md border border-dashed px-6 py-7 text-center outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${\n          isOver\n            ? \"border-ns-accent bg-ns-accent/[0.05]\"\n            : \"border-border bg-surface hover:border-foreground/25\"\n        }`}\n      >\n        <svg\n          aria-hidden\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className={`h-5 w-5 transition-colors duration-200 ${isOver ? \"text-ns-accent\" : \"text-ns-muted\"}`}\n        >\n          <rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"2\" />\n          <path d=\"M9 12h6M12 9v6\" />\n        </svg>\n        <p className=\"text-sm text-foreground\">\n          Drop files to seal, or{\" \"}\n          <button\n            type=\"button\"\n            onClick={(e) => {\n              e.stopPropagation();\n              openPicker();\n            }}\n            className=\"rounded-[4px] font-medium text-ns-accent underline-offset-2 outline-none transition-colors hover:underline focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface\"\n          >\n            browse\n          </button>\n        </p>\n        <p id={hintId} className=\"font-mono text-[11px] tracking-wide text-ns-muted\">\n          {hint}\n        </p>\n      </div>\n\n      <input\n        ref={inputRef}\n        type=\"file\"\n        multiple\n        accept={accept.length > 0 ? accept.join(\",\") : undefined}\n        tabIndex={-1}\n        aria-hidden=\"true\"\n        className=\"sr-only\"\n        onChange={(e) => {\n          processFiles(e.target.files);\n          e.target.value = \"\";\n        }}\n      />\n\n      {files.length > 0 && (\n        <ul role=\"list\" aria-label=\"Uploads\" className=\"flex flex-col\">\n          {files.map((f) => (\n            <FileCard key={f.id} file={f} reduced={reduced} frozen={frozenIdsRef.current.has(f.id)} />\n          ))}\n        </ul>\n      )}\n\n      <p role=\"status\" aria-live=\"polite\" className=\"min-h-4 font-mono text-[11px] text-ns-muted\">\n        {announce}\n      </p>\n\n      <style>{`\n        .ns-file-upload-seal-card[data-just-sealed] {\n          animation: ns-file-upload-seal-pucker ${PUCKER_MS}ms cubic-bezier(0.32, 1.6, 0.5, 1) both;\n        }\n        @keyframes ns-file-upload-seal-pucker {\n          0% { transform: scale(1); }\n          40% { transform: scale(0.99); }\n          100% { transform: scale(1); }\n        }\n        @media (prefers-reduced-motion: reduce) {\n          .ns-file-upload-seal-card[data-just-sealed] { animation: none; }\n        }\n      `}</style>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/file-upload-seal.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa",
      "error": "#ea001d"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717",
      "error": "#ff6369"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "dropzone",
      "file-upload",
      "progress",
      "form",
      "svg",
      "physics",
      "accessibility"
    ],
    "instruction": "Build a file upload zone (focusable role=button zone plus hidden <input type=file multiple>, real Enter/Space/click parity, HTML5 drag-and-drop as a bonus path) where each accepted file renders as a card with its own SVG rounded-rect outline drawn 24px outside the card's silhouette. One per-file critically-damped spring (k≈90, zeta=1) drives a single generalized 'seal progress' value dp (0 = loose outline 24px out, 1 = flush/taut) toward whatever the file's real upload progress currently is; every frame, direct SVG attribute writes (never React state) recompute the rect's x/y/width/height from that offset, its corner radius (grows slightly with offset so it always reads concentric with the card), and its stroke-dasharray, interpolating from loose stitches (8 4) at dp=0 to a taut near-solid line (1 0) at dp=1. Stroke color is a literal crossfade: two overlapping rects, one --ns-muted one --foreground, whose opacities are 1-dp and dp respectively — never a JS color-lerp. Because the upload driver reports progress in irregular chunks (not a smooth ramp) every ~170-280ms, and the spring re-targets on every chunk without ever fully settling between them, the outline visibly breathes toward the card rather than snapping in discrete steps. On arrival at progress 1 the file's status flips to 'sealed' and the card plays one one-shot CSS keyframe — scale(1) to scale(0.99) to scale(1) over 260ms — a physical click, not a fade. A failed upload's spring instead retargets to dp=0 (loose/open) with light underdamping (zeta≈0.4, lower k) so it visibly overshoots past the 24px rest point and wobbles before settling — the exact same spring engine, just less damped, its rects redrawn in a single dashed var(--error, #ea001d) stroke rather than the muted/foreground pair. Multiple files each get their own card, own spring, own outline; nothing is shared or queued. Files pre-supplied via defaultFiles (with an explicit status and, for 'uploading', a progress) render in that exact end state at mount with no entrance spring at all — their geometry is written once, synchronously, in a layout effect before first paint, so the idle screenshot is a deterministic gallery of a sealed file, a mid-upload file, and a failed file, not a random mid-animation frame. A consumer supplies an uploadFile(file, onProgress) => Promise function for real uploads (reject to fail the seal); omitting it runs a built-in simulated upload for prototyping. Accessibility: the seal SVG is aria-hidden and purely decorative; each card carries a real role=progressbar with continuously-updating aria-valuenow/aria-valuemax and an aria-valuetext that reads 'upload failed' or '58%' as appropriate (pull-model, not itself aria-live, so it never spams); a single shared role=status aria-live=polite line separately announces only at 25/50/75% and on the terminal sealed/failed outcome, never per-percent; and every card also carries a plain, non-live Geist Mono status line ('uploading 41.2 MB… 58%', '2.6 MB · sealed', 'upload failed · 184 KB') for sighted users, updating freely without triggering announcements. Under prefers-reduced-motion the spring is skipped entirely: dp snaps directly to the nearest quarter (0/0.25/0.5/0.75/1) on every progress update with no interpolation and no overshoot, the pucker keyframe is suppressed, and a failed upload snaps straight back to the loose 24px outline with no wobble — fully legible, just discrete. No canvas anywhere; the outline is real SVG measured against the card via ResizeObserver, matching the light and dark theme through --ns-muted/--foreground/--border tokens and the --error status token with its established repo-wide #ea001d fallback."
  },
  "type": "registry:ui"
}