{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload-thermal",
  "title": "File Upload Thermal",
  "description": "File dropzone as a thermal field: dragging spawns rising accent convection wisps, accepted drops buoy off the drop point and squash-land into a docked queue rack, rejected files sink and fade.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/file-upload-thermal/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type DragEvent,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// UpdraftDropzone — file dropzone as a thermal field. Dragging a file over\n// the zone spawns rising accent convection wisps on a canvas 2D layer (over\n// the control, behind the copy). Accepted drops buoy upward off the drop\n// point as DOM chips with damped sway, then squash-land into a docked queue\n// rack; rejected files sink 40px and fade. Accept vs reject is felt\n// physically, instantly. Direct-DOM rAF that sleeps when every chip is\n// docked and every wisp is dead; wisp ink is derived from CSS tokens at\n// mount and re-derived live on documentElement class changes.\n// ---------------------------------------------------------------------------\n\n// wisps\nconst SPAWN_RATE = 18; // wisps per second while dragging over\nconst WISP_CAP = 30; // max live wisps\nconst WISP_RISE_MIN = 30; // px/s\nconst WISP_RISE_MAX = 60; // px/s\nconst WISP_WOBBLE_AMP = 6; // px\nconst WISP_WOBBLE_PERIOD = 1.2; // s\nconst WISP_LIFE_MIN = 1.5; // s\nconst WISP_LIFE_MAX = 2.5; // s\nconst WISP_ALPHA = 0.12; // peak alpha, from --ns-accent\n// accepted-chip flight\nconst BUOYANCY = 900; // px/s^2 upward boost\nconst BUOY_T = 0.18; // boost window s\nconst FLY_K = 70; // spring pull toward dock slot s^-2\nconst FLY_ZETA = 0.95; // linear drag (near-critical)\nconst SWAY_AMP = 10; // px\nconst SWAY_HZ = 2;\nconst SWAY_DECAY = 6.28; // zeta~0.5 envelope at 2 Hz: exp(-zeta*omega*t)\nconst SETTLE_DEADLINE = 1.4; // forced-settle per chip s\nconst STAGGER_MS = 90; // multi-drop launch stagger\n// landing squash\nconst SQUASH_S = 0.88;\nconst SQUASH_HOLD = 0.12; // s at full squash\nconst SQUASH_K = 300; // s^-2\nconst SQUASH_ZETA = 0.6;\n// rejected-chip sink\nconst SINK_G = 600; // px/s^2\nconst SINK_DIST = 40; // px\nconst SINK_FADE = 0.5; // s\n\ntype Vec3 = readonly [number, number, number];\n\ninterface Wisp {\n  x: number;\n  y: number;\n  vy: number;\n  phase: number;\n  len: number;\n  life: number;\n  age: number;\n}\ninterface Flight {\n  el: HTMLElement;\n  ox: number;\n  oy: number;\n  vx: number;\n  vy: number;\n  t: number; // negative during stagger wait\n  phase: \"fly\" | \"squash\";\n  squashT: number;\n  s: number;\n  sv: number;\n}\ninterface SinkState {\n  id: string;\n  vy: number;\n  dy: number;\n  age: number;\n}\n\nexport interface UpdraftFile {\n  id: string;\n  name: string;\n  size: number;\n  type: string;\n}\ninterface RejectChip {\n  id: string;\n  name: string;\n  x: number;\n  y: number;\n}\n\nfunction parseColor(raw: string): Vec3 | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);\n      const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);\n      const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    return null;\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];\n  // non-hex/rgb tokens (oklch(), hsl(), lab(), color(), named colors, …) —\n  // let the browser normalize the value instead of regexing every possible\n  // CSS color syntax: write it into a throwaway element and read back\n  // getComputedStyle(...).color, which browsers always resolve to rgb()/rgba().\n  if (typeof document === \"undefined\") return null;\n  const probe = document.createElement(\"span\");\n  probe.style.color = s;\n  if (!probe.style.color) return null; // invalid value, not just an unhandled format\n  document.body.appendChild(probe);\n  const resolved = getComputedStyle(probe).color;\n  document.body.removeChild(probe);\n  const m2 = resolved.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m2 ? [Number(m2[1]), Number(m2[2]), Number(m2[3])] : null;\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 typeIconPath(name: string, type: string): ReactNode {\n  const lower = name.toLowerCase();\n  if (type.startsWith(\"image/\") || /\\.(png|jpe?g|gif|webp|svg)$/.test(lower)) {\n    return (\n      <>\n        <rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" />\n        <circle cx=\"9\" cy=\"9\" r=\"2\" />\n        <path d=\"m21 15-3.5-3.5a2 2 0 0 0-3 0L6 20\" />\n      </>\n    );\n  }\n  if (type === \"application/pdf\" || /\\.(pdf|docx?|txt|md|log)$/.test(lower)) {\n    return (\n      <>\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        <path d=\"M9 13h6M9 17h4\" />\n      </>\n    );\n  }\n  return (\n    <>\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    </>\n  );\n}\n\nexport function UpdraftDropzone({\n  defaultFiles = [],\n  accept = [],\n  maxSizeBytes = 8 * 1024 * 1024,\n  onFilesChange,\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Upload files\",\n}: {\n  /** files shown pre-docked in the queue rack */\n  defaultFiles?: { name: string; size: number; type: string }[];\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  /** called with the full file list after any add or removal */\n  onFilesChange?: (files: UpdraftFile[]) => 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 canvasRef = useRef<HTMLCanvasElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const chipEls = useRef(new Map<string, HTMLLIElement>());\n  const idSeq = useRef(0);\n  const dragDepth = useRef(0);\n  const reducedRef = useRef(false);\n  const pendingRef = useRef<\n    { id: string; cx: number; cy: number; delay: number }[]\n  >([]);\n  const engineRef = useRef<{\n    setDrag: (on: boolean) => void;\n    dragPoint: (clientX: number) => void;\n    fly: (el: HTMLElement, cx: number, cy: number, delayMs: number) => void;\n    sink: (el: HTMLElement, id: string) => void;\n  } | null>(null);\n\n  const [files, setFiles] = useState<UpdraftFile[]>(() =>\n    defaultFiles.map((f, i) => ({ ...f, id: `uf-d${i}` }))\n  );\n  const [rejects, setRejects] = useState<RejectChip[]>([]);\n  const [isOver, setIsOver] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  const hintId = useId();\n  const liveId = useId();\n\n  // -- engine: wisp canvas + chip physics, direct-DOM only ------------------\n  useEffect(() => {\n    const root = rootRef.current;\n    const zone = zoneRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !zone || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    reducedRef.current = reduced;\n\n    // token-derived ink: read at mount, re-derived on theme class change\n    let accentInk: Vec3 = [0, 107, 255];\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      accentInk = parseColor(cs.getPropertyValue(\"--ns-accent\")) ?? accentInk;\n    };\n    derive();\n\n    // hot-path state: locals only, never React state\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let sized = false;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let dragging = false;\n    let dragLeft = 0; // cached zone rect left while dragging\n    let pointerX = -1; // canvas-local, -1 = unknown\n    let spawnAcc = 0;\n    let inkOn = false; // canvas has pixels that need a final clear\n    const wisps: Wisp[] = [];\n    const flights = new Map<HTMLElement, Flight>();\n    const sinks = new Map<HTMLElement, SinkState>();\n\n    const finishSink = (id: string) =>\n      setRejects((rs) => rs.filter((r) => r.id !== id));\n\n    const clearCanvas = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n    };\n\n    const resize = () => {\n      const rect = zone.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        sized = false; // zero-size guard — never spawn into nothing\n        return;\n      }\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      // canvas is a replaced element — inset alone does not size it\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      sized = true;\n      clearCanvas();\n    };\n    resize();\n\n    const settleChip = (f: Flight) => {\n      f.el.style.transform = \"\";\n      f.el.style.transformOrigin = \"\";\n      f.el.style.opacity = \"\";\n      f.el.style.zIndex = \"\";\n      f.el.style.willChange = \"\";\n      flights.delete(f.el);\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (!visible || document.hidden) {\n        last = 0;\n        return; // paused offscreen/hidden — wake() resumes\n      }\n      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n      last = now;\n      let active = false;\n\n      // -- wisps: pruned array, full clear + redraw every frame -------------\n      if (sized) {\n        if (dragging) {\n          spawnAcc += SPAWN_RATE * dt;\n          while (spawnAcc >= 1) {\n            spawnAcc -= 1;\n            if (wisps.length >= WISP_CAP) continue;\n            // bias spawn toward the dragged pointer's column\n            const cx = pointerX >= 0 ? pointerX : w / 2;\n            const x =\n              Math.random() < 0.7\n                ? Math.min(w - 2, Math.max(2, cx + (Math.random() - 0.5) * 140))\n                : Math.random() * w;\n            wisps.push({\n              x,\n              y: h - 2 - Math.random() * 10,\n              vy: -(WISP_RISE_MIN + Math.random() * (WISP_RISE_MAX - WISP_RISE_MIN)),\n              phase: Math.random() * Math.PI * 2,\n              len: 10 + Math.random() * 8,\n              life: WISP_LIFE_MIN + Math.random() * (WISP_LIFE_MAX - WISP_LIFE_MIN),\n              age: 0,\n            });\n          }\n          active = true;\n        }\n        if (wisps.length > 0) {\n          clearCanvas();\n          inkOn = true;\n          ctx.lineCap = \"round\";\n          ctx.lineWidth = 1.5;\n          for (let i = wisps.length - 1; i >= 0; i--) {\n            const wp = wisps[i];\n            if (!wp) continue;\n            wp.age += dt;\n            wp.y += wp.vy * dt;\n            if (wp.age >= wp.life || wp.y + wp.len < 0) {\n              wisps.splice(i, 1);\n              continue;\n            }\n            const xd =\n              wp.x +\n              WISP_WOBBLE_AMP *\n                Math.sin((wp.age / WISP_WOBBLE_PERIOD) * Math.PI * 2 + wp.phase);\n            const a = WISP_ALPHA * Math.sin((Math.PI * wp.age) / wp.life);\n            ctx.strokeStyle = `rgba(${accentInk[0]},${accentInk[1]},${accentInk[2]},${a.toFixed(3)})`;\n            ctx.beginPath();\n            ctx.moveTo(xd, wp.y);\n            ctx.quadraticCurveTo(xd + 3, wp.y + wp.len * 0.5, xd, wp.y + wp.len);\n            ctx.stroke();\n          }\n          active = true;\n        } else if (inkOn) {\n          clearCanvas(); // one explicit final clear, then the layer is inert\n          inkOn = false;\n        }\n      }\n\n      // -- accepted-chip flights: OFFSET transforms toward the dock slot ----\n      for (const f of flights.values()) {\n        if (!f.el.isConnected) {\n          flights.delete(f.el);\n          continue;\n        }\n        active = true;\n        f.t += dt;\n        if (f.t < 0) continue; // stagger wait, chip hidden at drop offset\n        if (f.phase === \"fly\") {\n          if (f.el.style.opacity !== \"1\") f.el.style.opacity = \"1\";\n          const c = 2 * FLY_ZETA * Math.sqrt(FLY_K);\n          const boost = f.t < BUOY_T ? BUOYANCY : 0;\n          f.vx += (-FLY_K * f.ox - c * f.vx) * dt;\n          f.vy += (-FLY_K * f.oy - c * f.vy - boost) * dt;\n          f.ox += f.vx * dt;\n          f.oy += f.vy * dt;\n          const arrived =\n            Math.abs(f.ox) < 1 &&\n            Math.abs(f.oy) < 1 &&\n            Math.abs(f.vx) + Math.abs(f.vy) < 24;\n          if (arrived || f.t >= SETTLE_DEADLINE) {\n            // forced-settle deadline — a chip never hovers past 1.4s\n            f.phase = \"squash\";\n            f.squashT = 0;\n            f.s = SQUASH_S;\n            f.sv = 0;\n            f.el.style.transformOrigin = \"50% 100%\";\n            f.el.style.transform = `scaleY(${SQUASH_S})`;\n          } else {\n            const sway =\n              SWAY_AMP *\n              Math.exp(-SWAY_DECAY * f.t) *\n              Math.sin(Math.PI * 2 * SWAY_HZ * f.t);\n            f.el.style.transform = `translate3d(${(f.ox + sway).toFixed(2)}px, ${f.oy.toFixed(2)}px, 0)`;\n          }\n        } else {\n          f.squashT += dt;\n          if (f.squashT >= SQUASH_HOLD) {\n            const c = 2 * SQUASH_ZETA * Math.sqrt(SQUASH_K);\n            f.sv += (-SQUASH_K * (f.s - 1) - c * f.sv) * dt;\n            f.s += f.sv * dt;\n            if (Math.abs(f.s - 1) < 0.004 && Math.abs(f.sv) < 0.08) {\n              settleChip(f);\n              continue;\n            }\n          }\n          f.el.style.transform = `scaleY(${f.s.toFixed(4)})`;\n        }\n      }\n\n      // -- rejected-chip sinks ----------------------------------------------\n      for (const [el, s] of sinks) {\n        if (!el.isConnected) {\n          sinks.delete(el);\n          continue;\n        }\n        s.age += dt;\n        s.vy += SINK_G * dt;\n        s.dy = Math.min(SINK_DIST, s.dy + s.vy * dt);\n        el.style.transform = `translate3d(0, ${s.dy.toFixed(2)}px, 0)`;\n        el.style.opacity = Math.max(0, 1 - s.age / SINK_FADE).toFixed(3);\n        if (s.age >= SINK_FADE) {\n          sinks.delete(el);\n          finishSink(s.id);\n        } else {\n          active = true;\n        }\n      }\n\n      if (active) raf = requestAnimationFrame(loop);\n      else last = 0; // sleep — all chips docked, all wisps dead\n    };\n\n    const wake = () => {\n      if (!raf && !reduced) raf = requestAnimationFrame(loop);\n    };\n\n    engineRef.current = {\n      setDrag: (on) => {\n        if (reduced) return; // static token-border highlight only\n        dragging = on;\n        if (on) {\n          dragLeft = zone.getBoundingClientRect().left;\n          spawnAcc = 0;\n          wake();\n        }\n      },\n      dragPoint: (clientX) => {\n        pointerX = clientX - dragLeft;\n      },\n      fly: (el, cx, cy, delayMs) => {\n        if (reduced) return; // chips appear docked instantly\n        const r = el.getBoundingClientRect();\n        if (r.width < 2 || r.height < 2) return; // zero-size guard — dock instantly\n        const f: Flight = {\n          el,\n          ox: cx - (r.left + r.width / 2),\n          oy: cy - (r.top + r.height / 2),\n          vx: 0,\n          vy: 0,\n          t: -delayMs / 1000,\n          phase: \"fly\",\n          squashT: 0,\n          s: 1,\n          sv: 0,\n        };\n        el.style.willChange = \"transform\";\n        el.style.zIndex = \"20\";\n        el.style.opacity = delayMs > 0 ? \"0\" : \"1\";\n        el.style.transform = `translate3d(${f.ox.toFixed(2)}px, ${f.oy.toFixed(2)}px, 0)`;\n        flights.set(el, f);\n        wake();\n      },\n      sink: (el, id) => {\n        if (sinks.has(el)) return;\n        sinks.set(el, { id, vy: 0, dy: 0, age: 0 });\n        wake();\n      },\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(zone);\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible) wake();\n    });\n    io.observe(root);\n    // live theme re-derive: watch documentElement class flips\n    const mo = new MutationObserver(derive);\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n    const onVis = () => {\n      if (!document.hidden) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      wisps.length = 0;\n      flights.clear();\n      sinks.clear();\n      engineRef.current = null;\n    };\n  }, []);\n\n  // -- landing-card autoplay bridge -----------------------------------------\n  // The component's only live interaction is the native HTML5 drag/drop API\n  // (dragenter/dragover/dragleave/drop with a real DataTransfer) — there is\n  // no pointer/hover/press affordance to synthesize against, so the shared\n  // autoplay driver (which only dispatches Pointer/Mouse events) cannot\n  // reach it as-is. This bridges the driver's synthetic pointer sweep into\n  // the exact same setDrag/dragPoint calls the real dragenter/dragover\n  // handlers already make, and — on the synthetic \"leave\" that closes each\n  // sweep — into the exact same processFiles() the real onDrop handler\n  // calls, with a File built via the standard constructible DataTransfer.\n  // No new animation or state machine: every visual (wisps, border, the\n  // buoy-and-dock flight) is the engine that already exists for real drops.\n  // Native listeners (not React props) mirror the pattern this repo already\n  // uses for driver-driven hover (dock-cursor-magnify's\n  // addEventListener(\"pointerleave\", ...)) so the driver's directly-\n  // dispatched, non-bubbling pointerleave is caught reliably.\n  // Gated on [data-autoplay-root]: the preview route only ever stamps that\n  // attribute for `embed=1&autoplay=1` cards, so a real mouse hover on\n  // /preview/file-upload-thermal (or plain ?embed=1) never wires this up and\n  // behaves exactly as before.\n  useEffect(() => {\n    const zone = zoneRef.current;\n    if (!zone || !zone.closest(\"[data-autoplay-root]\")) return;\n    let dragging = false;\n    const baseline = () => defaultFiles.map((f, i) => ({ ...f, id: `uf-d${i}` }));\n    const onMove = (e: PointerEvent) => {\n      if (!dragging) {\n        dragging = true;\n        setIsOver(true);\n        engineRef.current?.setDrag(true);\n      }\n      engineRef.current?.dragPoint(e.clientX);\n    };\n    const onLeave = () => {\n      if (!dragging) return;\n      dragging = false;\n      setIsOver(false);\n      engineRef.current?.setDrag(false);\n      // bound growth: clear the chip the *previous* sweep dropped before\n      // adding this sweep's — so a chip stays docked and visible for a\n      // full cycle (not just the ~300ms rest beat) and a card left running\n      // never accumulates chips.\n      setFiles(baseline());\n      onFilesChange?.(baseline());\n      // demo \"drop\": a synthetic accepted file rides the same buoy-and-dock\n      // flight a real drop uses, via the same processFiles() a real onDrop\n      // calls — accept-types validation runs on it exactly as it would on\n      // a real file.\n      const dt = new DataTransfer();\n      dt.items.add(new File([\"demo\"], \"render-log.txt\", { type: \"text/plain\" }));\n      processFiles(dt.files, null);\n    };\n    zone.addEventListener(\"pointermove\", onMove);\n    zone.addEventListener(\"pointerleave\", onLeave);\n    return () => {\n      zone.removeEventListener(\"pointermove\", onMove);\n      zone.removeEventListener(\"pointerleave\", onLeave);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [defaultFiles]);\n\n  // launch flights for freshly docked chips — layout effect so the offset\n  // transform lands before paint (no flash of the chip already docked)\n  useLayoutEffect(() => {\n    const pend = pendingRef.current;\n    if (pend.length === 0) return;\n    pendingRef.current = [];\n    for (const p of pend) {\n      const el = chipEls.current.get(p.id);\n      if (el) engineRef.current?.fly(el, p.cx, p.cy, p.delay);\n    }\n  }, [files]);\n\n  const validate = (file: File): string | null => {\n    if (file.size > maxSizeBytes)\n      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 = (\n    list: FileList | null,\n    point: { x: number; y: number } | null\n  ) => {\n    if (!list || list.length === 0) return;\n    const zr = zoneRef.current?.getBoundingClientRect();\n    const px = point?.x ?? (zr ? zr.left + zr.width / 2 : 0);\n    const py = point?.y ?? (zr ? zr.top + zr.height / 2 : 0);\n    const accepted: UpdraftFile[] = [];\n    const newRejects: RejectChip[] = [];\n    const messages: string[] = [];\n    for (const file of Array.from(list)) {\n      const reason = validate(file);\n      if (reason === null) {\n        accepted.push({\n          id: `uf-${idSeq.current++}`,\n          name: file.name,\n          size: file.size,\n          type: file.type,\n        });\n      } else {\n        messages.push(`${file.name} rejected — ${reason}`);\n        if (!reducedRef.current && zr) {\n          newRejects.push({\n            id: `rj-${idSeq.current++}`,\n            name: file.name,\n            x: Math.min(zr.width - 8, Math.max(8, px - zr.left)),\n            y: Math.min(zr.height - 8, Math.max(8, py - zr.top)),\n          });\n        }\n      }\n    }\n    if (accepted.length > 0) {\n      if (!reducedRef.current) {\n        pendingRef.current.push(\n          ...accepted.map((f, i) => ({\n            id: f.id,\n            cx: px,\n            cy: py,\n            delay: i * STAGGER_MS, // stagger so flights never overlap\n          }))\n        );\n      }\n      const next = [...files, ...accepted];\n      setFiles(next);\n      onFilesChange?.(next);\n      messages.push(...accepted.map((f) => `${f.name} added`));\n    }\n    if (newRejects.length > 0) setRejects((rs) => [...rs, ...newRejects]);\n    if (messages.length > 0) setAnnounce(messages.join(\". \"));\n  };\n\n  const removeFile = (id: string) => {\n    const i = files.findIndex((f) => f.id === id);\n    if (i === -1) return;\n    const removed = files[i];\n    const neighbor = files[i + 1] ?? files[i - 1];\n    const next = files.filter((f) => f.id !== id);\n    setFiles(next);\n    onFilesChange?.(next);\n    if (removed) setAnnounce(`${removed.name} removed`);\n    queueMicrotask(() => {\n      (neighbor\n        ? chipEls.current.get(neighbor.id)\n        : zoneRef.current\n      )?.focus();\n    });\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) {\n      setIsOver(true);\n      engineRef.current?.setDrag(true);\n    }\n  };\n  const onDragOver = (e: DragEvent<HTMLDivElement>) => {\n    if (!dragHasFiles(e)) return;\n    e.preventDefault();\n    if (e.dataTransfer) e.dataTransfer.dropEffect = \"copy\";\n    engineRef.current?.dragPoint(e.clientX);\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) {\n      setIsOver(false);\n      engineRef.current?.setDrag(false);\n    }\n  };\n  const onDrop = (e: DragEvent<HTMLDivElement>) => {\n    if (!dragHasFiles(e)) return;\n    e.preventDefault();\n    dragDepth.current = 0;\n    setIsOver(false);\n    engineRef.current?.setDrag(false);\n    processFiles(e.dataTransfer?.files ?? null, {\n      x: e.clientX,\n      y: e.clientY,\n    });\n  };\n\n  const hint = `${acceptSummary(accept)} · max ${formatSize(maxSizeBytes)}`;\n\n  return (\n    <div ref={rootRef} className={`flex flex-col gap-3 ${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-[8.5rem] cursor-pointer flex-col items-center justify-center gap-1.5 overflow-hidden rounded-md border border-dashed px-6 py-8 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        <canvas\n          ref={canvasRef}\n          aria-hidden\n          className=\"pointer-events-none absolute left-0 top-0\"\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={`relative z-[1] h-5 w-5 transition-colors duration-200 ${\n            isOver ? \"text-ns-accent\" : \"text-ns-muted\"\n          }`}\n        >\n          <path d=\"M12 16V4\" />\n          <path d=\"m7 9 5-5 5 5\" />\n          <path d=\"M4 20h16\" />\n        </svg>\n        <p className=\"relative z-[1] text-sm text-foreground\">\n          Drop files to attach, 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\n          id={hintId}\n          className=\"relative z-[1] font-mono text-[11px] tracking-wide text-ns-muted\"\n        >\n          {hint}\n        </p>\n\n        {/* rejected files sink and fade inside the zone */}\n        {rejects.map((r) => (\n          <div\n            key={r.id}\n            aria-hidden\n            className=\"pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-1/2\"\n            style={{ left: r.x, top: r.y }}\n          >\n            <div\n              ref={(el) => {\n                if (el) engineRef.current?.sink(el, r.id);\n              }}\n              className=\"flex max-w-[11rem] items-center gap-1.5 rounded-sm border border-border bg-background px-2 py-1 shadow-sm\"\n            >\n              <span className=\"truncate text-[11px] text-ns-muted line-through\">\n                {r.name}\n              </span>\n            </div>\n          </div>\n        ))}\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, null);\n          e.target.value = \"\";\n        }}\n      />\n\n      {files.length > 0 && (\n        <ul role=\"list\" aria-label=\"Attached files\" className=\"flex flex-wrap gap-2\">\n          {files.map((f) => (\n            <li\n              key={f.id}\n              ref={(el) => {\n                if (el) chipEls.current.set(f.id, el);\n                else chipEls.current.delete(f.id);\n              }}\n              tabIndex={0}\n              aria-label={`${f.name}, ${formatSize(f.size)}. Press Delete to remove.`}\n              onKeyDown={(e) => {\n                if (e.key === \"Delete\" || e.key === \"Backspace\") {\n                  e.preventDefault();\n                  removeFile(f.id);\n                }\n              }}\n              className=\"relative flex items-center gap-2 rounded-sm border border-border bg-surface py-1.5 pl-2.5 pr-1.5 outline-none transition-colors hover:border-foreground/25 focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\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-3.5 w-3.5 shrink-0 text-ns-muted\"\n              >\n                {typeIconPath(f.name, f.type)}\n              </svg>\n              <span className=\"max-w-[10rem] truncate text-xs text-foreground\">\n                {f.name}\n              </span>\n              <span className=\"font-mono text-[10px] text-ns-muted\">\n                {formatSize(f.size)}\n              </span>\n              <button\n                type=\"button\"\n                aria-label={`Remove ${f.name}`}\n                onClick={() => removeFile(f.id)}\n                className=\"rounded-[4px] p-0.5 text-ns-muted outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ns-accent\"\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                  className=\"h-3 w-3\"\n                >\n                  <path d=\"M18 6 6 18M6 6l12 12\" />\n                </svg>\n              </button>\n            </li>\n          ))}\n        </ul>\n      )}\n\n      <p\n        id={liveId}\n        role=\"status\"\n        aria-live=\"polite\"\n        className=\"min-h-4 font-mono text-[11px] text-ns-muted\"\n      >\n        {announce}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/file-upload-thermal.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"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "dropzone",
      "file-upload",
      "form",
      "canvas",
      "particles",
      "physics",
      "input"
    ],
    "instruction": "Build a file dropzone as a thermal field: a canvas 2D wisp layer over the zone (canvas-over-control pattern, explicit style.width/height because a canvas is a replaced element) plus DOM file chips animated with direct-DOM transforms, and a hidden <input type=file multiple> behind a real browse button. DRAGOVER: spawn convection wisps at 18/s (cap 30 live) from the zone floor biased toward the dragged pointer's column, rising 30-60 px/s with sinusoidal x-wobble amplitude 6px period 1.2s, life 1.5-2.5s, drawn as short quadratic strokes at peak alpha 0.12 from --ns-accent with a sin-envelope fade; pruned particle array with a full clear + redraw per frame, never destination-in fades. ACCEPTED CHIP: chips are DOM list items anchored to their dock slots and flown with OFFSET transforms measured from drop point to slot center: a 900 px/s^2 buoyancy boost for the first 180ms feeding a near-critically damped spring pull to the slot, horizontal sway +-10px at 2 Hz with a zeta~0.5 decay envelope, landing squash scaleY 0.88 held 120ms then sprung back with k=300 s^-2 zeta=0.6; multi-drop flights stagger 90ms (chips hidden at the drop offset until their turn) so flights never overlap, and every chip carries a forced-settle deadline of 1.4s. REJECTED CHIP: spawns at the drop point inside the zone, sinks 40px max under 600 px/s^2 gravity while fading over 500ms, then is removed; the rejection reason (type not accepted / exceeds size limit) and each accepted file's name are announced via a polite aria-live status line. INTERACTION: zone is focusable role=button with Enter/Space opening the picker, aria-describedby points at the accepted-types + max-size rules line, docked chips are focusable list items removable via Delete/Backspace or an explicit remove button with focus handed to a neighbor. Validation supports extensions (.png), exact mime, and mime wildcards (image/*) plus a per-file byte limit. REDUCED MOTION: no wisps, chips appear docked instantly, dragover falls back to the static token-border accent highlight. ENGINE: all canvas ink parsed from getComputedStyle CSS tokens at mount and re-derived live via a MutationObserver on documentElement class so both themes render correctly; single direct-DOM rAF loop that sleeps when all chips are docked and all wisps dead, pauses offscreen via IntersectionObserver and on document hide, guards zero-size zones and zero-size chips before animating, and tears down every listener, observer, and frame on unmount. Hover/focus/dragover affordances are token-relative (border-foreground/25, ring-ns-accent, border-ns-accent), never hardcoded white."
  },
  "type": "registry:ui"
}