{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "basketball-upload",
  "title": "Basketball Upload",
  "description": "File upload where you shoot the file through a hoop. Miss and you get the rebound; make it and the net fills up as the progress bar.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "components/ui/basketball-upload.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { Check, Plus, Upload, X } from 'lucide-react'\n\nimport { cn } from '@/lib/utils'\n\n// Sizes, in CSS pixels of the court.\nconst FILE_W = 46\nconst FILE_H = 58\nconst BALL_R = 20 // the file collides as a circle\nconst RIM_HALF = 56\nconst RIM_DEPTH = 8\nconst RIM_TUBE = 5\nconst NET_H = 84\nconst FLOOR_GAP = 54 // room under the file for its name\n\nconst GRAVITY = 2400 // px/s²\nconst POWER = 12 // launch speed per pixel of pull\nconst MAX_PULL = 120\nconst MIN_PULL = 14\nconst DEAD_ZONE = RIM_HALF + 62 // no clean arc starts closer to the hoop\nconst AIM_DOTS = 8\n\nexport type BasketballUploadHandle = {\n  /** Takes an assisted shot; false if no file was ready. `miss` sends it long. */\n  shoot: (options?: { miss?: boolean }) => boolean\n  /** Puts files on the floor, as if the user had picked them. */\n  add: (files: File[]) => void\n}\n\nexport type BasketballUploadProps = {\n  /** Files that went through the hoop. */\n  value?: File[]\n  onValueChange?: (files: File[]) => void\n  /** Uploads a scored file. `report(0..1)` fills the net; reject to drop it back out. */\n  onUpload?: (\n    file: File,\n    report: (progress: number) => void,\n  ) => Promise<unknown>\n  accept?: string\n  /** Largest file allowed, in bytes. */\n  maxSize?: number\n  multiple?: boolean\n  disabled?: boolean\n  title?: string\n  description?: string\n  className?: string\n}\n\ntype Queued = { id: number; file: File; at?: { x: number; y: number } }\ntype Mood = 'idle' | 'miss' | 'uploading' | 'done' | 'error'\ntype Tone = 'idle' | 'active' | 'miss' | 'done' | 'error'\ntype Geometry = {\n  width: number\n  floorY: number\n  homeX: number\n  rimX: number\n  rimY: number\n  dead: number\n  board: { left: number; top: number; right: number; bottom: number }\n}\n\nconst TONES: Record<\n  Tone,\n  { icon: typeof Upload; panel: string; chip: string; target: string }\n> = {\n  idle: {\n    icon: Upload,\n    panel: 'border-border',\n    chip: 'bg-blue-600',\n    target: 'border-muted-foreground/30',\n  },\n  active: {\n    icon: Upload,\n    panel: 'border-blue-500 ring-4 ring-blue-500/10',\n    chip: 'bg-blue-600',\n    target: 'border-blue-500',\n  },\n  miss: {\n    icon: X,\n    panel: 'border-orange-400 ring-4 ring-orange-400/15',\n    chip: 'bg-orange-500',\n    target: 'border-orange-400',\n  },\n  done: {\n    icon: Check,\n    panel: 'border-emerald-500 ring-4 ring-emerald-500/15',\n    chip: 'bg-emerald-600',\n    target: 'border-emerald-500',\n  },\n  error: {\n    icon: X,\n    panel: 'border-red-500 ring-4 ring-red-500/15',\n    chip: 'bg-red-600',\n    target: 'border-red-500',\n  },\n}\n\nconst NET_ROWS = 6\nconst NET_COLS = 6\n\n/** A knot of the net: `row` runs 0..NET_ROWS down, `col` 0..NET_COLS across. */\nfunction netPoint(row: number, col: number) {\n  const t = row / NET_ROWS\n  const u = (col / NET_COLS) * 2 - 1\n  const half = (RIM_HALF - 3) * (1 - t ** 0.8) + 27 * t ** 0.8\n  const sag = RIM_DEPTH * (1 - 0.45 * t) * Math.sqrt(1 - u * u)\n  const x = RIM_HALF + half * u\n  const y = t * (NET_H - RIM_DEPTH) + sag\n  return `${x.toFixed(1)} ${y.toFixed(1)}`\n}\n\nconst NET_MESH = (() => {\n  const d: string[] = []\n  for (let row = 0; row < NET_ROWS; row++) {\n    const shift = row % 2 ? 0.5 : 0\n    for (let col = shift; col <= NET_COLS - shift; col++) {\n      for (const next of [col - 0.5, col + 0.5]) {\n        if (next < 0 || next > NET_COLS) continue\n        d.push(`M${netPoint(row, col)}L${netPoint(row + 1, next)}`)\n      }\n    }\n  }\n  const hem = Array.from({ length: NET_COLS + 1 }, (_, col) =>\n    netPoint(NET_ROWS, col),\n  )\n  return `${d.join('')}M${hem.join('L')}`\n})()\n\nconst NET_SHAPE = (() => {\n  const points: string[] = []\n  for (let col = 0; col <= NET_COLS; col++) points.push(netPoint(0, col))\n  for (let row = 1; row <= NET_ROWS; row++)\n    points.push(netPoint(row, NET_COLS - (row % 2) / 2))\n  for (let col = NET_COLS - 1; col >= 0; col--)\n    points.push(netPoint(NET_ROWS, col))\n  for (let row = NET_ROWS - 1; row >= 1; row--)\n    points.push(netPoint(row, (row % 2) / 2))\n  return `M${points.join('L')}Z`\n})()\n\nfunction Net({\n  filled,\n  className,\n  style,\n}: {\n  filled?: boolean\n  className?: string\n  style?: React.CSSProperties\n}) {\n  return (\n    <svg\n      viewBox={`0 0 ${RIM_HALF * 2} ${NET_H}`}\n      className={cn('absolute inset-0 size-full overflow-visible', className)}\n      style={style}\n      aria-hidden=\"true\"\n    >\n      {filled ? (\n        <path d={NET_SHAPE} fill=\"currentColor\" fillOpacity={0.16} />\n      ) : null}\n      <path\n        d={NET_MESH}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n\n/** Half of the rim. The far half sits behind the file, the near half in front. */\nfunction RimArc({ front, className }: { front?: boolean; className?: string }) {\n  const w = RIM_HALF + RIM_TUBE\n  const h = RIM_DEPTH + RIM_TUBE\n  return (\n    <svg\n      viewBox={`${-w} ${-h} ${w * 2} ${h * 2}`}\n      className={cn('absolute overflow-visible', className)}\n      style={{ left: -w, top: -h, width: w * 2, height: h * 2 }}\n      aria-hidden=\"true\"\n    >\n      <path\n        d={`M${-RIM_HALF} 0A${RIM_HALF} ${RIM_DEPTH} 0 0 ${front ? 0 : 1} ${RIM_HALF} 0`}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={RIM_TUBE * 1.4}\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  )\n}\n\nfunction badgeColor(ext: string) {\n  if (ext === 'pdf') return 'fill-orange-500'\n  if (/^(png|jpe?g|gif|webp|svg|avif)$/.test(ext)) return 'fill-violet-500'\n  if (/^(xlsx?|csv)$/.test(ext)) return 'fill-emerald-500'\n  if (/^(docx?|txt|md)$/.test(ext)) return 'fill-blue-500'\n  return 'fill-slate-500'\n}\n\nfunction FileIcon({ name, className }: { name: string; className?: string }) {\n  const ext = /\\.([a-z0-9]{1,4})$/i.exec(name)?.[1].toLowerCase() ?? 'file'\n  return (\n    <svg\n      viewBox={`0 0 ${FILE_W} ${FILE_H}`}\n      className={cn('size-full drop-shadow-sm', className)}\n      aria-hidden=\"true\"\n    >\n      <path\n        d=\"M7 .75h21.5L45.25 17.5V51A6.25 6.25 0 0 1 39 57.25H7A6.25 6.25 0 0 1 .75 51V7A6.25 6.25 0 0 1 7 .75Z\"\n        className=\"fill-background stroke-foreground/20\"\n        strokeWidth={1.5}\n      />\n      <path\n        d=\"M28.5 1v10.5a6 6 0 0 0 6 6H45\"\n        fill=\"none\"\n        className=\"stroke-foreground/20\"\n        strokeWidth={1.5}\n      />\n      <rect\n        x={6}\n        y={33}\n        width={29}\n        height={15}\n        rx={4}\n        className={badgeColor(ext)}\n      />\n      <text\n        x={20.5}\n        y={43.6}\n        textAnchor=\"middle\"\n        className=\"fill-white text-[8.5px] font-bold\"\n      >\n        {ext.toUpperCase()}\n      </text>\n    </svg>\n  )\n}\n\nfunction formatBytes(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`\n  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`\n  return `${(bytes / 1024 / 1024).toFixed(1)} MB`\n}\n\nfunction accepts(file: File, accept?: string) {\n  if (!accept) return true\n  return accept.split(',').some((raw) => {\n    const rule = raw.trim().toLowerCase()\n    if (rule.startsWith('.')) return file.name.toLowerCase().endsWith(rule)\n    if (rule.endsWith('/*')) return file.type.startsWith(rule.slice(0, -1))\n    return file.type === rule\n  })\n}\n\nfunction simulateUpload(_file: File, report: (progress: number) => void) {\n  return new Promise<void>((resolve) => {\n    const started = performance.now()\n    const tick = () => {\n      const progress = Math.min(1, (performance.now() - started) / 1400)\n      report(progress)\n      if (progress < 1) requestAnimationFrame(tick)\n      else resolve()\n    }\n    requestAnimationFrame(tick)\n  })\n}\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(Math.max(value, min), max)\n\nlet nextId = 0\n\nexport const BasketballUpload = React.forwardRef<\n  BasketballUploadHandle,\n  BasketballUploadProps\n>(function BasketballUpload(\n  {\n    value,\n    onValueChange,\n    onUpload,\n    accept,\n    maxSize,\n    multiple = false,\n    disabled = false,\n    title = 'Upload a file',\n    description,\n    className,\n  },\n  ref,\n) {\n  const courtRef = React.useRef<HTMLDivElement>(null)\n  const boardRef = React.useRef<HTMLDivElement>(null)\n  const rimRef = React.useRef<HTMLDivElement>(null)\n  const netRef = React.useRef<HTMLDivElement>(null)\n  const ballRef = React.useRef<HTMLButtonElement>(null)\n  const shadowRef = React.useRef<HTMLDivElement>(null)\n  const labelRef = React.useRef<HTMLDivElement>(null)\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const dotRefs = React.useRef<(HTMLSpanElement | null)[]>([])\n\n  const [queue, setQueue] = React.useState<Queued[]>([])\n  const [inner, setInner] = React.useState<File[]>([])\n  const [mood, setMood] = React.useState<Mood>('idle')\n  const [notice, setNotice] = React.useState({ title: '', text: '' })\n  const [progress, setProgress] = React.useState(0)\n  const [phase, setPhase] = React.useState<'away' | 'resting' | 'aiming'>(\n    'away',\n  )\n  const [hot, setHot] = React.useState(false)\n  const [dropping, setDropping] = React.useState(false)\n  const [hint, setHint] = React.useState(false)\n  const [attempts, setAttempts] = React.useState(0)\n  const [tally, setTally] = React.useState({ made: 0, shots: 0 })\n\n  const files = value ?? inner\n  const active = queue[0]\n\n  // The flight runs outside React; state is set only when something visible changes.\n  const geo = React.useRef<Geometry | null>(null)\n  const frame = React.useRef(0)\n  const alive = React.useRef(true)\n  const timers = React.useRef(new Set<ReturnType<typeof setTimeout>>())\n  const moodTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n  const sim = React.useRef({\n    mode: 'hidden' as 'hidden' | 'rest' | 'aim' | 'fly' | 'net',\n    x: 0,\n    y: 0,\n    vx: 0,\n    vy: 0,\n    rot: 0,\n    spin: 0,\n    anchorX: 0,\n    anchorY: 0,\n    grabX: 0,\n    grabY: 0,\n    pullX: 0,\n    pullY: 0,\n    stretch: 0,\n    live: false, // this flight is a shot, not a file dropping in\n    above: false,\n    rimHit: false,\n    judged: false,\n    hot: false,\n    behind: false,\n  })\n  const snapshot = {\n    active,\n    files,\n    value,\n    onValueChange,\n    onUpload,\n    accept,\n    maxSize,\n    multiple,\n    mood,\n    attempts,\n  }\n  const latest = React.useRef(snapshot)\n  React.useEffect(() => {\n    latest.current = snapshot\n  })\n\n  const later = (fn: () => void, ms: number) => {\n    const id = setTimeout(() => {\n      timers.current.delete(id)\n      fn()\n    }, ms)\n    timers.current.add(id)\n    return id\n  }\n\n  /** Shows a mood for a while, then goes back to idle. */\n  const flash = (next: Mood, ms: number) => {\n    clearTimeout(moodTimer.current)\n    setMood(next)\n    moodTimer.current = later(\n      () => setMood((current) => (current === next ? 'idle' : current)),\n      ms,\n    )\n  }\n\n  const commit = (next: File[]) => {\n    if (latest.current.value === undefined) setInner(next)\n    latest.current.onValueChange?.(next)\n  }\n\n  // Divided by any ancestor CSS scale, so the court works inside a scaled preview.\n  const measure = () => {\n    const court = courtRef.current\n    const rim = rimRef.current\n    const board = boardRef.current\n    if (!court || !rim || !board) return geo.current\n    const box = court.getBoundingClientRect()\n    const k = box.width / court.offsetWidth || 1\n    const at = (el: Element) => {\n      const r = el.getBoundingClientRect()\n      return {\n        left: (r.left - box.left) / k,\n        top: (r.top - box.top) / k,\n        right: (r.right - box.left) / k,\n        bottom: (r.bottom - box.top) / k,\n      }\n    }\n    const hoop = at(rim)\n    const width = court.offsetWidth\n    geo.current = {\n      width,\n      floorY: court.offsetHeight - FLOOR_GAP - FILE_H / 2,\n      homeX: Math.max(BALL_R + 8, hoop.left - DEAD_ZONE - 6),\n      rimX: hoop.left,\n      rimY: hoop.top,\n      dead: Math.min(DEAD_ZONE, hoop.left - BALL_R - 6),\n      board: at(board),\n    }\n    return geo.current\n  }\n\n  const toLocal = (event: { clientX: number; clientY: number }) => {\n    const court = courtRef.current\n    if (!court) return { x: 0, y: 0 }\n    const box = court.getBoundingClientRect()\n    const k = box.width / court.offsetWidth || 1\n    return {\n      x: (event.clientX - box.left) / k,\n      y: (event.clientY - box.top) / k,\n    }\n  }\n\n  const paint = () => {\n    const s = sim.current\n    const g = geo.current\n    if (!g) return\n    const ball = ballRef.current\n    if (ball) {\n      const pull =\n        s.mode === 'aim' ? Math.hypot(s.pullX, s.pullY) / MAX_PULL : 0\n      ball.style.visibility = s.mode === 'hidden' ? 'hidden' : 'visible'\n      ball.style.zIndex = s.behind ? '20' : '40'\n      ball.style.transform = `translate3d(${s.x - FILE_W / 2}px,${s.y - FILE_H / 2}px,0) rotate(${s.rot}deg) scale(${1 + pull * 0.08},${1 - pull * 0.12})`\n    }\n    const lift = clamp((g.floorY - s.y) / 240, 0, 1)\n    if (shadowRef.current) {\n      const shown = s.mode !== 'hidden' && s.mode !== 'net'\n      shadowRef.current.style.opacity = shown ? String(1 - lift * 0.85) : '0'\n      shadowRef.current.style.transform = `translate3d(${s.x - 22}px,0,0) scale(${1 - lift * 0.5})`\n    }\n    if (labelRef.current) {\n      labelRef.current.style.transform = `translate3d(${clamp(s.x - 70, 4, g.width - 144)}px,0,0)`\n    }\n    if (netRef.current) {\n      netRef.current.style.transform = `scaleY(${1 + s.stretch})`\n    }\n  }\n\n  /** Draws the first half second of where the shot would go. */\n  const paintAim = () => {\n    const s = sim.current\n    const armed = s.mode === 'aim' && Math.hypot(s.pullX, s.pullY) >= MIN_PULL\n    dotRefs.current.forEach((dot, i) => {\n      if (!dot) return\n      const t = (i + 1) * 0.06\n      const x = s.x - s.pullX * POWER * t\n      const y = s.y - s.pullY * POWER * t + 0.5 * GRAVITY * t * t\n      dot.style.opacity = armed ? String(0.9 - i * 0.1) : '0'\n      dot.style.transform = `translate3d(${x - 3}px,${y - 3}px,0) scale(${1 - i * 0.07})`\n    })\n  }\n\n  const cool = () => {\n    if (!sim.current.hot) return\n    sim.current.hot = false\n    setHot(false)\n  }\n\n  const upload = async (entry: Queued) => {\n    clearTimeout(moodTimer.current)\n    setMood('uploading')\n    setProgress(0)\n    const send = latest.current.onUpload ?? simulateUpload\n    // Thinned out: a report every frame restarts the fill transition before it moves.\n    let shownAt = 0\n    try {\n      await send(entry.file, (amount) => {\n        const now = performance.now()\n        if (!alive.current || (amount < 1 && now - shownAt < 150)) return\n        shownAt = now\n        setProgress(clamp(amount, 0, 1))\n      })\n    } catch (error) {\n      if (!alive.current) return\n      // Hand it back: the file falls out of the net.\n      Object.assign(sim.current, {\n        mode: 'fly',\n        behind: false,\n        stretch: 0,\n        vx: 260,\n        vy: 60,\n        spin: 320,\n      })\n      setNotice({\n        title: 'Rejected at the rim',\n        text:\n          error instanceof Error && error.message\n            ? error.message\n            : 'The upload failed. Shoot again.',\n      })\n      flash('error', 2600)\n      run()\n      return\n    }\n    if (!alive.current) return\n    setMood('done')\n    commit(\n      latest.current.multiple\n        ? [...latest.current.files, entry.file]\n        : [entry.file],\n    )\n    later(() => {\n      setQueue((items) => items.filter((item) => item.id !== entry.id))\n      setMood('idle')\n    }, 1500)\n  }\n\n  const score = () => {\n    const s = sim.current\n    const dunked = s.live ? 0 : 1 // dropped straight in, without a shot\n    setTally((t) => ({ made: t.made + 1, shots: t.shots + dunked }))\n    Object.assign(s, { mode: 'net', behind: true, live: false })\n    cool()\n    if (latest.current.active) void upload(latest.current.active)\n  }\n\n  const miss = () => {\n    const s = sim.current\n    s.judged = true\n    cool()\n    const lines = s.rimHit\n      ? ['Brick!', 'Off the rim']\n      : s.above\n        ? ['Airball!', 'Not even the rim']\n        : ['Way short', 'That barely left the floor']\n    setNotice({\n      title: lines[latest.current.attempts % lines.length],\n      text: 'Grab the rebound and shoot again.',\n    })\n    flash('miss', 1500)\n  }\n\n  /** Advances the file by `dt` seconds. Returns false once nothing moves. */\n  const step = (dt: number): boolean => {\n    const s = sim.current\n    const g = geo.current\n    if (!g) return false\n    const upright = Math.round(s.rot / 360) * 360\n\n    if (s.mode === 'rest') {\n      s.rot += (upright - s.rot) * Math.min(1, dt * 12)\n      if (Math.abs(upright - s.rot) > 0.4) return true\n      s.rot = 0\n      return false\n    }\n\n    if (s.mode === 'net') {\n      // Caught: the file hangs on a spring in the pocket of the net.\n      const pocket = g.rimY + NET_H * 0.48\n      s.vx += (-(s.x - g.rimX) * 220 - s.vx * 18) * dt\n      s.vy += (-(s.y - pocket) * 220 - s.vy * 15) * dt\n      s.x += s.vx * dt\n      s.y += s.vy * dt\n      s.rot += (upright - s.rot) * Math.min(1, dt * 6)\n      s.stretch = clamp((s.y - pocket) / NET_H, 0, 0.3)\n      return Math.abs(s.vx) + Math.abs(s.vy) > 4 || Math.abs(s.y - pocket) > 0.5\n    }\n\n    if (s.mode !== 'fly') return false\n\n    const prevY = s.y\n    s.vy += GRAVITY * dt\n    s.x += s.vx * dt\n    s.y += s.vy * dt\n    s.rot += s.spin * dt\n\n    if (s.x < BALL_R) {\n      s.x = BALL_R\n      s.vx = Math.abs(s.vx) * 0.55\n      s.spin *= -0.5\n    } else if (s.x > g.width - BALL_R) {\n      s.x = g.width - BALL_R\n      s.vx = -Math.abs(s.vx) * 0.55\n      s.spin *= -0.5\n    }\n\n    // The two ends of the rim are the only solid parts of the hoop.\n    for (const side of [-1, 1]) {\n      const dx = s.x - (g.rimX + side * RIM_HALF)\n      const dy = s.y - g.rimY\n      const dist = Math.hypot(dx, dy)\n      const reach = BALL_R + RIM_TUBE\n      if (dist >= reach) continue\n      const nx = dist ? dx / dist : side\n      const ny = dist ? dy / dist : 0\n      s.x += nx * (reach - dist)\n      s.y += ny * (reach - dist)\n      const into = s.vx * nx + s.vy * ny\n      if (into < 0) {\n        s.vx -= 1.5 * into * nx\n        s.vy -= 1.5 * into * ny\n      }\n      s.vx += Math.sign(dx || side) * 12 // never balances on the rim\n      s.spin = s.vx * 1.4\n      if (s.live) s.rimHit = true\n    }\n\n    const dx = s.x - g.rimX\n    const inside = Math.abs(dx) < RIM_HALF - RIM_TUBE\n\n    // The net stops anything coming up from underneath.\n    if (\n      s.vy < 0 &&\n      s.y > g.rimY &&\n      s.y - BALL_R < g.rimY + NET_H &&\n      Math.abs(dx) < RIM_HALF - 14\n    ) {\n      s.vy = Math.abs(s.vy) * 0.3\n      s.vx *= 0.6\n    }\n\n    if (s.y < g.rimY) s.above = true\n    s.behind = s.vy > 0 && inside && s.y < g.rimY + BALL_R\n\n    if (prevY < g.rimY && s.y >= g.rimY) {\n      if (inside) {\n        score()\n        return true\n      }\n      if (s.live && !s.judged) miss()\n    }\n\n    const overBoard =\n      s.live &&\n      !s.judged &&\n      s.x > g.board.left &&\n      s.x < g.board.right &&\n      s.y > g.board.top &&\n      s.y < g.board.bottom\n    if (overBoard !== s.hot) {\n      s.hot = overBoard\n      setHot(overBoard)\n    }\n\n    // A pulled-back file starts below the floor line, so only landings count.\n    if (s.y >= g.floorY && s.vy >= 0) {\n      s.y = g.floorY\n      if (s.live && !s.judged) miss()\n      if (s.vy > 160) {\n        s.vy *= -0.4\n        s.vx *= 0.8\n        s.spin = s.vx * 1.2\n      } else {\n        s.vy = 0\n        // The floor slopes away from under the hoop, where no shot can start.\n        const stuck = Math.abs(dx) < g.dead\n        if (stuck) s.vx += (dx < 0 ? -1 : 1) * 700 * dt\n        else s.vx *= Math.max(0, 1 - 7 * dt)\n        s.spin = s.vx * 2\n        if (!stuck && Math.abs(s.vx) < 12) {\n          Object.assign(s, { mode: 'rest', vx: 0, spin: 0, live: false })\n          setPhase('resting')\n        }\n      }\n    }\n    return true\n  }\n\n  const run = () => {\n    if (frame.current) return\n    let last = performance.now()\n    const tick = (now: number) => {\n      const dt = Math.min(0.032, (now - last) / 1000)\n      last = now\n      let moving = true\n      for (let i = 0; i < 8 && moving; i++) moving = step(dt / 8)\n      paint()\n      frame.current = moving ? requestAnimationFrame(tick) : 0\n    }\n    frame.current = requestAnimationFrame(tick)\n  }\n\n  const launch = (vx: number, vy: number) => {\n    const s = sim.current\n    const g = measure()\n    if (!g) return\n    clearTimeout(moodTimer.current)\n    setMood('idle')\n    setHint(false)\n    setPhase('away')\n    setAttempts((n) => n + 1)\n    setTally((t) => ({ ...t, shots: t.shots + 1 }))\n    Object.assign(s, {\n      live: true,\n      above: false,\n      rimHit: false,\n      judged: false,\n      pullX: 0,\n      pullY: 0,\n    })\n    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n      // No flight: the file simply goes in.\n      Object.assign(s, {\n        x: g.rimX,\n        y: g.rimY + NET_H * 0.48,\n        vx: 0,\n        vy: 0,\n        rot: 0,\n      })\n      score()\n      paint()\n      return\n    }\n    Object.assign(s, { mode: 'fly', vx, vy, spin: clamp(vx * 1.4, -540, 540) })\n    run()\n  }\n\n  const shoot = (options?: { miss?: boolean }) => {\n    const s = sim.current\n    const g = measure()\n    if (!g || disabled || s.mode !== 'rest') return false\n    const apex = g.rimY - 110\n    const vy = -Math.sqrt(2 * GRAVITY * (s.y - apex))\n    const time = -vy / GRAVITY + Math.sqrt((2 * (g.rimY - apex)) / GRAVITY)\n    launch(((g.rimX - s.x) / time) * (options?.miss ? 1.5 : 1), vy)\n    return true\n  }\n\n  const add = (incoming: File[], at?: { x: number; y: number }) => {\n    const accepted: Queued[] = []\n    for (const file of incoming) {\n      const { accept, maxSize, mood } = latest.current\n      const problem = !accepts(file, accept)\n        ? 'is not a file type this hoop takes.'\n        : maxSize && file.size > maxSize\n          ? `is over the ${formatBytes(maxSize)} limit.`\n          : null\n      if (!problem) {\n        accepted.push({ id: nextId++, file, at })\n      } else if (mood !== 'uploading' && mood !== 'done') {\n        setNotice({\n          title: 'Not on the roster',\n          text: `${file.name} ${problem}`,\n        })\n        flash('error', 2600)\n      }\n    }\n    if (accepted.length === 0) return\n    const many = latest.current.multiple\n    const inNet = sim.current.mode === 'net'\n    setQueue((items) => {\n      if (many) return [...items, ...accepted]\n      // One file at a time: a new pick replaces the one still on the floor.\n      return inNet && items.length > 0 ? [items[0], accepted[0]] : [accepted[0]]\n    })\n  }\n\n  React.useImperativeHandle(ref, () => ({ shoot, add }))\n\n  React.useEffect(() => {\n    alive.current = true\n    const pending = timers.current\n    return () => {\n      alive.current = false\n      cancelAnimationFrame(frame.current)\n      frame.current = 0\n      pending.forEach(clearTimeout)\n      pending.clear()\n    }\n  }, [])\n\n  // A new file at the head of the queue drops onto the floor.\n  const activeId = active?.id\n  React.useEffect(() => {\n    const s = sim.current\n    const entry = latest.current.active\n    setAttempts(0)\n    setPhase('away')\n    if (!entry) {\n      s.mode = 'hidden'\n      return\n    }\n    const g = measure()\n    if (!g) return\n    Object.assign(s, {\n      mode: 'fly',\n      x: entry.at ? clamp(entry.at.x, BALL_R, g.width - BALL_R) : g.homeX,\n      y: entry.at ? Math.min(entry.at.y, g.floorY) : g.floorY - 80,\n      vx: 0,\n      vy: 0,\n      rot: entry.at ? 0 : -10,\n      spin: entry.at ? 0 : 60,\n      stretch: 0,\n      live: false,\n      hot: false,\n      behind: false,\n    })\n    paint()\n    run()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [activeId])\n\n  React.useEffect(() => {\n    const court = courtRef.current\n    if (!court) return\n    const observer = new ResizeObserver(() => {\n      const s = sim.current\n      const g = measure()\n      if (g && s.mode === 'rest') {\n        if (Math.abs(s.x - g.rimX) < g.dead) s.x = g.homeX\n        s.x = clamp(s.x, BALL_R, g.width - BALL_R)\n        s.y = g.floorY\n      }\n      paint()\n    })\n    observer.observe(court)\n    return () => observer.disconnect()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  const release = () => {\n    const s = sim.current\n    if (s.mode !== 'aim') return\n    const pulled = Math.hypot(s.pullX, s.pullY) >= MIN_PULL\n    const vx = -s.pullX * POWER\n    const vy = -s.pullY * POWER\n    s.mode = 'rest'\n    if (!pulled) {\n      Object.assign(s, { x: s.anchorX, y: s.anchorY, pullX: 0, pullY: 0 })\n      setPhase('resting')\n      setHint(true)\n      later(() => setHint(false), 2200)\n    }\n    paintAim()\n    paint()\n    if (pulled) launch(vx, vy)\n  }\n\n  const browse = () => inputRef.current?.click()\n\n  let heading = title\n  let text =\n    description ??\n    (active\n      ? 'Pull the file back and let go to shoot.'\n      : 'Drop a file on the court, or pick one.')\n  const name = active?.file.name ?? 'The file'\n  if (mood === 'miss' || mood === 'error') {\n    heading = notice.title\n    text = notice.text\n  } else if (mood === 'uploading') {\n    heading = 'Swish!'\n    text = `Uploading ${name}`\n  } else if (mood === 'done') {\n    heading = 'Nothing but net'\n    text =\n      attempts > 1\n        ? `${name} went in on attempt ${attempts}.`\n        : attempts === 1\n          ? `${name} went in on the first try.`\n          : `${name} was dunked. Bold.`\n  } else if (dropping) {\n    text = 'Drop it. You still have to make the shot.'\n  } else if (phase === 'aiming') {\n    text = 'Aim above the rim, then let go.'\n  } else if (hint) {\n    text = 'Pull it back first, like a slingshot.'\n  }\n\n  const tone: Tone =\n    mood === 'uploading' || (mood === 'idle' && (hot || dropping))\n      ? 'active'\n      : mood\n  const Chip = TONES[tone].icon\n  const filled = mood === 'uploading' ? progress : mood === 'done' ? 1 : 0\n  const hoopColor = mood === 'done' ? 'text-emerald-600' : 'text-blue-600'\n\n  return (\n    <div\n      data-slot=\"basketball-upload\"\n      className={cn(\n        'w-full max-w-[400px]',\n        disabled && 'pointer-events-none opacity-60',\n        className,\n      )}\n    >\n      <div\n        ref={courtRef}\n        data-slot=\"basketball-upload-court\"\n        className=\"relative isolate h-[440px] select-none overflow-hidden\"\n        onDragOver={(event) => {\n          if (disabled || !event.dataTransfer.types.includes('Files')) return\n          event.preventDefault()\n          setDropping(true)\n        }}\n        onDragLeave={(event) => {\n          if (!event.currentTarget.contains(event.relatedTarget as Node | null))\n            setDropping(false)\n        }}\n        onDrop={(event) => {\n          if (disabled) return\n          event.preventDefault()\n          setDropping(false)\n          add(Array.from(event.dataTransfer.files), toLocal(event))\n        }}\n      >\n        {/* No transform or z-index here: rim and net must layer against the file, a sibling. */}\n        <div\n          ref={boardRef}\n          className=\"absolute inset-x-0 top-4 mx-auto w-[272px] max-w-[calc(100%-2rem)] rounded-[22px] bg-muted p-1 pb-6\"\n        >\n          <div\n            className={cn(\n              'rounded-[18px] border bg-background px-4 pt-4 transition-[border-color,box-shadow] duration-200',\n              TONES[tone].panel,\n            )}\n          >\n            <div className=\"flex items-start gap-3\">\n              <div\n                className={cn(\n                  'mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-[10px] text-white transition-colors duration-200',\n                  TONES[tone].chip,\n                )}\n              >\n                <Chip className=\"size-4\" strokeWidth={2.5} aria-hidden=\"true\" />\n              </div>\n              <div className=\"min-w-0 flex-1\" aria-live=\"polite\">\n                <div className=\"truncate text-sm font-semibold leading-5\">\n                  {heading}\n                </div>\n                <div className=\"line-clamp-2 h-8 text-xs leading-4 text-muted-foreground\">\n                  {text}\n                  {mood === 'uploading' ? (\n                    <span aria-hidden=\"true\" className=\"tabular-nums\">\n                      {` · ${Math.round(progress * 100)}%`}\n                    </span>\n                  ) : null}\n                </div>\n              </div>\n            </div>\n            <div\n              className={cn(\n                'mx-auto mt-4 h-14 w-[76px] rounded-t-md border-2 border-b-0 transition-colors duration-200',\n                TONES[tone].target,\n              )}\n            />\n          </div>\n\n          <div ref={rimRef} className=\"absolute bottom-3 left-1/2 size-0\">\n            <RimArc\n              className={cn('z-10 brightness-75 transition-colors', hoopColor)}\n            />\n            <div\n              ref={netRef}\n              className=\"absolute z-30 origin-top\"\n              style={{\n                left: -RIM_HALF,\n                top: 0,\n                width: RIM_HALF * 2,\n                height: NET_H,\n              }}\n            >\n              <Net className=\"text-muted-foreground/45\" />\n              <Net\n                filled\n                className={cn(\n                  'transition-[clip-path,color] duration-150 ease-linear',\n                  hoopColor,\n                )}\n                style={{\n                  clipPath: `inset(${(1 - filled) * 100}% -6px -6px -6px)`,\n                }}\n              />\n            </div>\n            <RimArc front className={cn('z-30 transition-colors', hoopColor)} />\n          </div>\n        </div>\n\n        {active ? (\n          <>\n            <div\n              ref={shadowRef}\n              className=\"pointer-events-none absolute left-0 h-2 w-11 rounded-[100%] bg-foreground/20 opacity-0 blur-[3px]\"\n              style={{ bottom: FLOOR_GAP - 5 }}\n              aria-hidden=\"true\"\n            />\n            <button\n              key={active.id}\n              ref={ballRef}\n              type=\"button\"\n              disabled={disabled}\n              aria-label={`Shoot ${active.file.name}. Drag it back and release, or press Enter for an assisted shot.`}\n              className={cn(\n                'invisible absolute left-0 top-0 z-40 touch-none rounded-md outline-offset-4 outline-blue-600 will-change-transform focus-visible:outline-2',\n                phase === 'resting' && 'cursor-grab',\n                phase === 'aiming' && 'cursor-grabbing',\n                phase === 'away' && 'pointer-events-none',\n              )}\n              style={{ width: FILE_W, height: FILE_H }}\n              onPointerDown={(event) => {\n                const s = sim.current\n                if (s.mode !== 'rest' || event.button !== 0 || !measure())\n                  return\n                event.currentTarget.setPointerCapture(event.pointerId)\n                const point = toLocal(event)\n                Object.assign(s, {\n                  mode: 'aim',\n                  rot: 0,\n                  anchorX: s.x,\n                  anchorY: s.y,\n                  grabX: point.x,\n                  grabY: point.y,\n                  pullX: 0,\n                  pullY: 0,\n                })\n                setPhase('aiming')\n              }}\n              onPointerMove={(event) => {\n                const s = sim.current\n                if (s.mode !== 'aim') return\n                const point = toLocal(event)\n                let pullX = point.x - s.grabX\n                let pullY = point.y - s.grabY\n                const length = Math.hypot(pullX, pullY)\n                if (length > MAX_PULL) {\n                  pullX *= MAX_PULL / length\n                  pullY *= MAX_PULL / length\n                }\n                Object.assign(s, {\n                  pullX,\n                  pullY,\n                  x: s.anchorX + pullX * 0.3,\n                  y: s.anchorY + pullY * 0.3,\n                })\n                paint()\n                paintAim()\n              }}\n              onPointerUp={release}\n              onPointerCancel={() => {\n                sim.current.pullX = 0\n                sim.current.pullY = 0\n                release()\n              }}\n              onKeyDown={(event) => {\n                if (event.key !== 'Enter' && event.key !== ' ') return\n                event.preventDefault()\n                shoot()\n              }}\n            >\n              <FileIcon\n                name={active.file.name}\n                className={cn(\n                  'transition-[scale,opacity] duration-500',\n                  mood === 'uploading' && 'scale-75',\n                  mood === 'done' && 'scale-50 opacity-0',\n                )}\n              />\n            </button>\n            <div\n              ref={labelRef}\n              className={cn(\n                'pointer-events-none absolute bottom-2 left-0 w-[140px] text-center transition-opacity duration-200',\n                phase === 'resting' ? 'opacity-100' : 'opacity-0',\n              )}\n            >\n              <div className=\"truncate text-xs font-medium\">\n                {active.file.name}\n              </div>\n              <div className=\"text-[10px] text-muted-foreground\">\n                {formatBytes(active.file.size)}\n                {queue.length > 1 ? ` · ${queue.length - 1} more waiting` : ''}\n              </div>\n            </div>\n            {Array.from({ length: AIM_DOTS }, (_, i) => (\n              <span\n                key={i}\n                ref={(el) => {\n                  dotRefs.current[i] = el\n                }}\n                className=\"pointer-events-none absolute left-0 top-0 z-40 size-1.5 rounded-full bg-blue-600 opacity-0\"\n                aria-hidden=\"true\"\n              />\n            ))}\n          </>\n        ) : (\n          <button\n            type=\"button\"\n            onClick={browse}\n            disabled={disabled}\n            className=\"absolute flex -translate-x-1/2 flex-col items-center gap-2 rounded-md text-muted-foreground outline-offset-4 outline-blue-600 transition-colors hover:text-foreground focus-visible:outline-2\"\n            style={{\n              left: `max(${BALL_R + 8}px, calc(50% - ${DEAD_ZONE + 6}px))`,\n              bottom: FLOOR_GAP - 24,\n            }}\n          >\n            <span\n              className=\"flex items-center justify-center rounded-lg border-2 border-dashed border-current/40\"\n              style={{ width: FILE_W, height: FILE_H }}\n            >\n              <Plus className=\"size-4\" aria-hidden=\"true\" />\n            </span>\n            <span className=\"text-xs font-medium leading-4\">Pick a file</span>\n          </button>\n        )}\n      </div>\n\n      <div\n        data-slot=\"basketball-upload-footer\"\n        className=\"flex items-center justify-between gap-3 px-1 text-xs text-muted-foreground\"\n      >\n        <span className=\"tabular-nums\">\n          {tally.shots > 0\n            ? `${tally.made}/${tally.shots} from the field`\n            : 'No shots yet'}\n        </span>\n        <span className=\"flex items-center gap-3\">\n          {attempts >= 3 && phase === 'resting' ? (\n            <button\n              type=\"button\"\n              onClick={() => shoot()}\n              className=\"underline underline-offset-4 hover:text-foreground\"\n            >\n              Let a pro shoot\n            </button>\n          ) : null}\n          {active ? (\n            <button\n              type=\"button\"\n              onClick={browse}\n              className=\"underline underline-offset-4 hover:text-foreground\"\n            >\n              {multiple ? 'Add files' : 'Change file'}\n            </button>\n          ) : null}\n        </span>\n      </div>\n\n      {files.length > 0 ? (\n        <ul data-slot=\"basketball-upload-list\" className=\"mt-3 space-y-1.5\">\n          {files.map((file, index) => (\n            <li\n              key={`${file.name}-${index}`}\n              className=\"flex items-center gap-2 rounded-lg border bg-background py-1.5 pl-3 pr-1.5 text-sm\"\n            >\n              <Check\n                className=\"size-4 shrink-0 text-emerald-600\"\n                aria-hidden=\"true\"\n              />\n              <span className=\"min-w-0 flex-1 truncate\">{file.name}</span>\n              <span className=\"shrink-0 text-xs text-muted-foreground\">\n                {formatBytes(file.size)}\n              </span>\n              <button\n                type=\"button\"\n                aria-label={`Remove ${file.name}`}\n                onClick={() => commit(files.filter((_, i) => i !== index))}\n                className=\"flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground\"\n              >\n                <X className=\"size-3.5\" aria-hidden=\"true\" />\n              </button>\n            </li>\n          ))}\n        </ul>\n      ) : null}\n\n      <input\n        ref={inputRef}\n        type=\"file\"\n        className=\"sr-only\"\n        tabIndex={-1}\n        aria-hidden=\"true\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={(event) => {\n          add(Array.from(event.target.files ?? []))\n          event.target.value = ''\n        }}\n      />\n    </div>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "funny",
    "upload"
  ],
  "type": "registry:ui"
}