{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "penalty-upload",
  "title": "Penalty Upload",
  "description": "File upload as a penalty kick. Pick your spot, beat the keeper, and the goal fills up as the progress bar.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "components/ui/penalty-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\nconst FILE_W = 46\nconst FILE_H = 58\n// The goal mouth: left and width in % of the pitch, top and height in px.\nconst GOAL = { left: 24, width: 52, top: 140, height: 130 }\nconst GOAL_LINE = GOAL.top + GOAL.height\n\n// Markings are projected from one camera, CAMERA yards behind the goal line.\n// Depths are real (six-yard box, spot at 12, area at 18); widths are squeezed to fit.\nconst CAMERA = 96\nconst VANISH_Y = -330\nconst near = (depth: number) => CAMERA / (CAMERA - depth)\n/** Screen y of a point on the grass `depth` yards out from the goal line. */\nconst groundY = (depth: number) =>\n  VANISH_Y + (GOAL_LINE - VANISH_Y) * near(depth)\n/** A box `half` wide (% of the pitch, at the goal line) and `depth` yards deep. */\nconst box = (half: number, depth: number) => {\n  const y = (groundY(depth) - GOAL_LINE).toFixed(1)\n  const reach = half * near(depth)\n  return `M${50 - half} 0 ${(50 - reach).toFixed(1)} ${y}H${(50 + reach).toFixed(1)}L${50 + half} 0`\n}\nconst AREA_H = groundY(18) - GOAL_LINE\nconst AREA_HALF = 40 // the six-yard box starts at the posts, the area well outside\nconst MARKINGS = `M${50 - AREA_HALF} 0H${50 + AREA_HALF}${box(GOAL.width / 2, 6)}${box(AREA_HALF, 18)}`\nconst MARK_Y = groundY(12) // the penalty spot\nconst MARK = { width: 96, height: 26 } // a circle on the grass, seen at this angle\n\nconst SPOT = { x: 50, y: MARK_Y - FILE_H / 2 + 6, scale: 1, turn: 0, ms: 450 }\nconst KEEPER_HOME = { x: 50, y: GOAL_LINE - 44, tilt: 0 }\nconst SWAY_MS = 650\nconst FLIGHT_MS = 420\nconst READ_CHANCE = 0.42 // how often the keeper dives the right way\n\nexport type PenaltyUploadHandle = {\n  /** Takes a kick that scores; false if no file was ready. `miss` has it saved. */\n  shoot: (options?: { miss?: boolean }) => boolean\n  /** Puts files on the spot, as if the user had picked them. */\n  add: (files: File[]) => void\n}\n\nexport type PenaltyUploadProps = {\n  /** Files that ended up in the back of the net. */\n  value?: File[]\n  onValueChange?: (files: File[]) => void\n  /** Uploads a scored file. `report(0..1)` fills the goal; reject to rule it 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 }\ntype Mood = 'idle' | 'miss' | 'uploading' | 'done' | 'error'\ntype Tone = 'idle' | 'active' | 'miss' | 'done' | 'error'\ntype Outcome = 'goal' | 'saved' | 'wide' | 'over' | 'post' | 'bar'\n\nconst TONES: Record<\n  Tone,\n  { icon: typeof Upload; panel: string; chip: string }\n> = {\n  idle: { icon: Upload, panel: 'border-border', chip: 'bg-blue-600' },\n  active: {\n    icon: Upload,\n    panel: 'border-blue-500 ring-4 ring-blue-500/10',\n    chip: 'bg-blue-600',\n  },\n  miss: {\n    icon: X,\n    panel: 'border-orange-400 ring-4 ring-orange-400/15',\n    chip: 'bg-orange-500',\n  },\n  done: {\n    icon: Check,\n    panel: 'border-emerald-500 ring-4 ring-emerald-500/15',\n    chip: 'bg-emerald-600',\n  },\n  error: {\n    icon: X,\n    panel: 'border-red-500 ring-4 ring-red-500/15',\n    chip: 'bg-red-600',\n  },\n}\n\nconst CALLS: Record<Exclude<Outcome, 'goal'>, [string[], string]> = {\n  saved: [\n    ['Saved!', 'Denied!', 'Not today'],\n    'The keeper kept it out. Take it again.',\n  ],\n  wide: [['Wide!', 'Row Z'], 'That missed the goal. Take it again.'],\n  over: [['Over the bar!', 'Into orbit'], 'Keep it down. Take it again.'],\n  post: [['Off the post!'], 'So close. Take it again.'],\n  bar: [['Off the bar!'], 'So close. Take it again.'],\n}\n\n/** Where a shot at (u, v) ends up: u runs 0..1 post to post, v 0..1 bar to ground. */\nfunction judge(\n  u: number,\n  v: number,\n): Exclude<Outcome, 'goal' | 'saved'> | 'target' {\n  if (v < -0.05) return 'over'\n  if (u < -0.02 || u > 1.02) return 'wide'\n  if (u < 0.02 || u > 0.98) return 'post'\n  if (v < 0.05) return 'bar'\n  return 'target'\n}\n\nfunction Keeper({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 64 88\" className={className} aria-hidden=\"true\">\n      {/* legs apart, socks, boots on the bottom edge (the goal line) */}\n      <path\n        d=\"M28 60 24 72M36 60l4 12\"\n        className=\"stroke-orange-200\"\n        strokeWidth={6.5}\n        strokeLinecap=\"round\"\n      />\n      <path\n        d=\"M24 72 23 82M40 72l1 10\"\n        className=\"stroke-yellow-400\"\n        strokeWidth={6.5}\n        strokeLinecap=\"round\"\n      />\n      <path\n        d=\"M21 76.5h5.5M38.5 76.5H44\"\n        className=\"stroke-yellow-600\"\n        strokeWidth={1.5}\n      />\n      <rect\n        x={13}\n        y={81}\n        width={14}\n        height={7}\n        rx={3.5}\n        className=\"fill-foreground\"\n      />\n      <rect\n        x={37}\n        y={81}\n        width={14}\n        height={7}\n        rx={3.5}\n        className=\"fill-foreground\"\n      />\n      <path\n        d=\"M21 50h22l3 14H34l-2-6-2 6H18Z\"\n        className=\"fill-foreground/85\"\n        strokeLinejoin=\"round\"\n      />\n      {/* bent arms, hands up */}\n      <path\n        d=\"M22 32 11 25 9 13M42 32l11-7 2-12\"\n        fill=\"none\"\n        className=\"stroke-yellow-400\"\n        strokeWidth={7.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M5.5 17.5h7.5M51 17.5h7.5\"\n        className=\"stroke-yellow-600\"\n        strokeWidth={1.5}\n      />\n      <rect\n        x={2.75}\n        y={0.75}\n        width={13}\n        height={15}\n        rx={5.5}\n        className=\"fill-background stroke-foreground/70\"\n        strokeWidth={1.5}\n      />\n      <rect\n        x={48.25}\n        y={0.75}\n        width={13}\n        height={15}\n        rx={5.5}\n        className=\"fill-background stroke-foreground/70\"\n        strokeWidth={1.5}\n      />\n      <path\n        d=\"M6 5.5h6.5M51.5 5.5H58\"\n        className=\"stroke-foreground/40\"\n        strokeWidth={1}\n        strokeLinecap=\"round\"\n      />\n      {/* neck, jersey, collar, number */}\n      <rect x={29.5} y={19} width={5} height={8} className=\"fill-orange-300\" />\n      <path\n        d=\"M20 31q12-6 24 0l-1.5 22q-10.5 3-21 0Z\"\n        className=\"fill-yellow-400\"\n      />\n      <path\n        d=\"M27.5 27.5 32 32l4.5-4.5\"\n        fill=\"none\"\n        className=\"stroke-yellow-600\"\n        strokeWidth={2}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <text\n        x={32}\n        y={47}\n        textAnchor=\"middle\"\n        className=\"fill-yellow-950 text-[12px] font-bold\"\n      >\n        1\n      </text>\n      {/* head */}\n      <circle cx={32} cy={13} r={8} className=\"fill-orange-200\" />\n      <path\n        d=\"M24 12.5a8 8 0 0 1 16 0c-3-3.5-13-3.5-16 0Z\"\n        className=\"fill-amber-900\"\n      />\n      <circle cx={29} cy={14.5} r={0.9} className=\"fill-neutral-800\" />\n      <circle cx={35} cy={14.5} r={0.9} className=\"fill-neutral-800\" />\n      <path\n        d=\"M30 17.8q2 1 4 0\"\n        fill=\"none\"\n        className=\"stroke-neutral-800\"\n        strokeWidth={0.8}\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 PenaltyUpload = React.forwardRef<\n  PenaltyUploadHandle,\n  PenaltyUploadProps\n>(function PenaltyUpload(\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 pitchRef = React.useRef<HTMLDivElement>(null)\n  const aimRef = React.useRef<HTMLDivElement>(null)\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const netId = React.useId()\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 [stage, setStage] = React.useState<'ready' | 'flying' | 'net'>('ready')\n  const [ball, setBall] = React.useState(SPOT)\n  const [keeper, setKeeper] = React.useState(KEEPER_HOME)\n  const [keeperGone, setKeeperGone] = React.useState(false)\n  const [sway, setSway] = React.useState(1)\n  const [dropping, setDropping] = React.useState(false)\n  const [attempts, setAttempts] = React.useState(0)\n  const [results, setResults] = React.useState<boolean[]>([])\n\n  const files = value ?? inner\n  const active = queue[0]\n\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 snapshot = {\n    active,\n    files,\n    value,\n    onValueChange,\n    onUpload,\n    accept,\n    maxSize,\n    multiple,\n    mood,\n    stage,\n    attempts,\n    keeperGone,\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  const respot = () => {\n    setBall(SPOT)\n    setKeeper(KEEPER_HOME)\n    setStage('ready')\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      setNotice({\n        title: 'Ruled out by VAR',\n        text:\n          error instanceof Error && error.message\n            ? error.message\n            : 'The upload failed. Take it again.',\n      })\n      flash('error', 2600)\n      respot()\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  /** Kicks at (u, v) of the goal mouth; see judge(). */\n  const kick = (u: number, v: number, rigged?: 'goal' | 'save') => {\n    const { active: entry, stage, keeperGone, attempts } = latest.current\n    if (!entry || stage !== 'ready' || disabled) return false\n    latest.current.stage = 'flying' // a second click must not kick again\n    if (!rigged) {\n      // Nobody hits exactly what they aim at.\n      u += (Math.random() - 0.5) * 0.07\n      v += (Math.random() - 0.5) * 0.14\n    }\n    v = Math.min(v, 0.92)\n\n    const landing = judge(u, v)\n    const side = u < 0.36 ? -1 : u > 0.64 ? 1 : 0\n    const elsewhere = [-1, 0, 1].filter((s) => s !== side)\n    const readsIt = rigged ? rigged === 'save' : Math.random() < READ_CHANCE\n    const dive = keeperGone\n      ? null\n      : readsIt\n        ? side\n        : elsewhere[Math.floor(Math.random() * elsewhere.length)]\n    // Even a keeper who guesses right cannot reach the top corners.\n    const topCorner = v < 0.26 && Math.abs(u - 0.5) > 0.34\n    const outcome: Outcome =\n      landing !== 'target'\n        ? landing\n        : dive === side && !topCorner\n          ? 'saved'\n          : 'goal'\n\n    const x = GOAL.left + u * GOAL.width\n    const y = GOAL.top + v * GOAL.height\n    clearTimeout(moodTimer.current)\n    setMood('idle')\n    setStage('flying')\n    setAttempts((n) => n + 1)\n    setBall({ x, y, scale: 0.5, turn: 320, ms: FLIGHT_MS })\n\n    if (dive !== null) {\n      later(() => {\n        if (dive === 0) {\n          setKeeper({ ...KEEPER_HOME, y: KEEPER_HOME.y - (v < 0.4 ? 18 : 0) })\n        } else if (dive === side) {\n          setKeeper({ x: x - dive * 9, y: clamp(y, 168, 250), tilt: dive * 72 })\n        } else {\n          setKeeper({\n            x: 50 + dive * GOAL.width * 0.3,\n            y: 240,\n            tilt: dive * 72,\n          })\n        }\n      }, 70)\n    }\n\n    later(() => {\n      setResults((list) => [...list, outcome === 'goal'])\n      if (outcome === 'goal') {\n        setStage('net')\n        void upload(entry)\n        return\n      }\n      const [titles, text] = CALLS[outcome]\n      setNotice({ title: titles[attempts % titles.length], text })\n      flash('miss', 1700)\n      const inward = u < 0.5 ? 1 : -1\n      const rebound = { y: 326, scale: 0.8, turn: 560, ms: 500 }\n      setBall(\n        {\n          over: { x, y: -70, scale: 0.35, turn: 640, ms: 500 },\n          wide: {\n            x: x - inward * 34,\n            y: y - 30,\n            scale: 0.4,\n            turn: 640,\n            ms: 500,\n          },\n          saved: { ...rebound, x: x - inward * 12, y: 318 },\n          post: { ...rebound, x: x + inward * 12 },\n          bar: { ...rebound, x: x + inward * 12 },\n        }[outcome],\n      )\n      later(respot, 1100)\n    }, FLIGHT_MS)\n    return true\n  }\n\n  const add = (incoming: File[]) => {\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 goal takes.'\n        : maxSize && file.size > maxSize\n          ? `is over the ${formatBytes(maxSize)} limit.`\n          : null\n      if (!problem) {\n        accepted.push({ id: nextId++, file })\n      } else if (mood !== 'uploading' && mood !== 'done') {\n        setNotice({\n          title: 'Not in the squad',\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 = latest.current.stage === '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 spot.\n      return inNet && items.length > 0 ? [items[0], accepted[0]] : [accepted[0]]\n    })\n  }\n\n  React.useImperativeHandle(ref, () => ({\n    shoot: (options) =>\n      options?.miss ? kick(0.78, 0.6, 'save') : kick(0.86, 0.22, 'goal'),\n    add,\n  }))\n\n  React.useEffect(() => {\n    alive.current = true\n    const pending = timers.current\n    return () => {\n      alive.current = false\n      pending.forEach(clearTimeout)\n      pending.clear()\n    }\n  }, [])\n\n  // A new file at the head of the queue goes on the spot, keeper back in goal.\n  const activeId = active?.id\n  React.useEffect(() => {\n    respot()\n    setKeeperGone(false)\n    setAttempts(0)\n  }, [activeId])\n\n  // The keeper shuffles along the line while waiting.\n  const waiting = stage === 'ready' && activeId !== undefined && !keeperGone\n  React.useEffect(() => {\n    if (!waiting) return\n    const id = setInterval(() => setSway((s) => -s), SWAY_MS)\n    return () => clearInterval(id)\n  }, [waiting])\n\n  const toPitch = (event: { clientX: number; clientY: number }) => {\n    const pitch = pitchRef.current\n    if (!pitch) return { x: 50, y: 0 }\n    const box = pitch.getBoundingClientRect()\n    return {\n      x: ((event.clientX - box.left) / box.width) * 100,\n      y: ((event.clientY - box.top) / box.height) * pitch.offsetHeight,\n    }\n  }\n\n  const browse = () => inputRef.current?.click()\n\n  const canKick = Boolean(active) && stage === 'ready' && !disabled\n  const name = active?.file.name ?? 'The file'\n  let heading = title\n  let text =\n    description ??\n    (active ? 'Click a spot in the goal to shoot.' : 'Drop a file or pick one.')\n  if (mood === 'miss' || mood === 'error') {\n    heading = notice.title\n    text = notice.text\n  } else if (mood === 'uploading') {\n    heading = 'GOAL!'\n    text = `Uploading ${name}`\n  } else if (mood === 'done') {\n    heading = 'Back of the net'\n    text =\n      attempts > 1\n        ? `${name} went in on kick ${attempts}.`\n        : `${name} went in with the first kick.`\n  } else if (dropping) {\n    text = 'Drop it, then beat the keeper.'\n  } else if (keeperGone && active) {\n    text = 'The keeper went home. Open goal.'\n  }\n\n  const tone: Tone =\n    mood === 'uploading' || (mood === 'idle' && dropping) ? 'active' : mood\n  const Chip = TONES[tone].icon\n  const filled = mood === 'uploading' ? progress : mood === 'done' ? 1 : 0\n  const made = results.filter(Boolean).length\n  const lastFive = results.slice(-5)\n  const keeperX = keeperGone ? 124 : waiting ? 50 + sway * 5 : keeper.x\n\n  return (\n    <div\n      data-slot=\"penalty-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={pitchRef}\n        data-slot=\"penalty-upload-pitch\"\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))\n        }}\n      >\n        <div className=\"absolute inset-x-0 top-4 mx-auto w-[304px] max-w-[calc(100%-2rem)] rounded-[22px] bg-muted p-1\">\n          <div\n            className={cn(\n              'flex items-center gap-3 rounded-[18px] border bg-background p-3 transition-[border-color,box-shadow] duration-200',\n              TONES[tone].panel,\n            )}\n          >\n            <div\n              className={cn(\n                '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\">\n              <div className=\"flex items-center justify-between gap-2\">\n                <div\n                  className=\"truncate text-sm font-semibold leading-5\"\n                  aria-live=\"polite\"\n                >\n                  {heading}\n                </div>\n                <div\n                  className=\"flex shrink-0 gap-1\"\n                  role=\"img\"\n                  aria-label={`${made} of ${results.length} kicks scored`}\n                >\n                  {Array.from({ length: 5 }, (_, i) => (\n                    <span\n                      key={i}\n                      className={cn(\n                        'size-2 rounded-full transition-colors',\n                        lastFive[i] === undefined\n                          ? 'border border-muted-foreground/40'\n                          : lastFive[i]\n                            ? 'bg-emerald-500'\n                            : 'bg-red-500',\n                      )}\n                    />\n                  ))}\n                </div>\n              </div>\n              <div\n                className=\"truncate text-xs leading-4 text-muted-foreground\"\n                aria-live=\"polite\"\n              >\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\n        {/* Goal: net, progress fill, then posts and bar on top. */}\n        <div\n          className=\"absolute\"\n          style={{\n            left: `${GOAL.left}%`,\n            width: `${GOAL.width}%`,\n            top: GOAL.top,\n            height: GOAL.height,\n          }}\n        >\n          <svg\n            className=\"absolute inset-0 size-full text-muted-foreground/30\"\n            aria-hidden=\"true\"\n          >\n            <defs>\n              <pattern\n                id={netId}\n                width={13}\n                height={13}\n                patternUnits=\"userSpaceOnUse\"\n              >\n                <path\n                  d=\"M13 0H0V13\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth={1}\n                />\n              </pattern>\n            </defs>\n            <rect width=\"100%\" height=\"100%\" fill={`url(#${netId})`} />\n          </svg>\n          <div\n            className={cn(\n              'absolute inset-x-0 bottom-0 transition-[height,background-color] duration-150 ease-linear',\n              mood === 'done' ? 'bg-emerald-500/25' : 'bg-blue-500/25',\n            )}\n            style={{ height: `${filled * 100}%` }}\n          />\n          <div\n            className={cn(\n              'absolute -inset-x-[5px] -top-[5px] bottom-0 rounded-t-lg border-[5px] border-b-0 transition-colors duration-200',\n              mood === 'done' ? 'border-emerald-600' : 'border-foreground/75',\n            )}\n          />\n        </div>\n\n        {/* Goal line, six-yard box and penalty area. */}\n        <svg\n          viewBox={`0 0 100 ${AREA_H}`}\n          preserveAspectRatio=\"none\"\n          className=\"absolute inset-x-0 w-full text-muted-foreground/40\"\n          style={{ top: GOAL_LINE, height: AREA_H }}\n          aria-hidden=\"true\"\n        >\n          <path\n            d={MARKINGS}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1}\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        </svg>\n        {active ? (\n          <div\n            className=\"absolute -translate-x-1/2 -translate-y-1/2 rounded-[100%] border border-muted-foreground/40\"\n            style={{ left: '50%', top: MARK_Y, ...MARK }}\n            aria-hidden=\"true\"\n          />\n        ) : null}\n\n        <div\n          className={cn(\n            'pointer-events-none absolute z-10 h-[88px] w-16 ease-out motion-reduce:transition-none',\n            waiting && 'ease-in-out',\n            keeperGone ? 'opacity-0' : 'opacity-100',\n          )}\n          style={{\n            left: `${keeperX}%`,\n            top: keeper.y,\n            transform: `translate(-50%, -50%) rotate(${keeper.tilt}deg)`,\n            transitionProperty: 'left, top, transform, opacity',\n            transitionDuration: `${waiting ? SWAY_MS : 360}ms`,\n          }}\n        >\n          <Keeper className=\"size-full\" />\n        </div>\n\n        {/* Where the pointer can aim: the goal plus room to miss. */}\n        <div\n          role=\"presentation\"\n          className={cn(\n            'absolute inset-x-0 z-20',\n            canKick ? 'cursor-crosshair' : 'pointer-events-none',\n          )}\n          style={{ top: GOAL.top - 28, height: GOAL.height + 46 }}\n          onPointerMove={(event) => {\n            const aim = aimRef.current\n            if (!aim || event.pointerType === 'touch') return\n            const point = toPitch(event)\n            aim.style.left = `${point.x}%`\n            aim.style.top = `${point.y}px`\n            aim.style.opacity = '1'\n          }}\n          onPointerLeave={() => {\n            if (aimRef.current) aimRef.current.style.opacity = '0'\n          }}\n          onClick={(event) => {\n            const point = toPitch(event)\n            if (aimRef.current) aimRef.current.style.opacity = '0'\n            kick(\n              (point.x - GOAL.left) / GOAL.width,\n              (point.y - GOAL.top) / GOAL.height,\n            )\n          }}\n        />\n        <div\n          ref={aimRef}\n          className={cn(\n            'pointer-events-none absolute z-20 flex size-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 border-blue-600 opacity-0 transition-opacity duration-150',\n            !canKick && 'hidden',\n          )}\n          aria-hidden=\"true\"\n        >\n          <span className=\"size-1 rounded-full bg-blue-600\" />\n        </div>\n\n        {active ? (\n          <>\n            <div\n              className={cn(\n                'pointer-events-none absolute h-2 w-11 -translate-x-1/2 rounded-[100%] bg-foreground/20 blur-[3px] transition-opacity duration-200',\n                stage === 'ready' ? 'opacity-100' : 'opacity-0',\n              )}\n              style={{ left: `${SPOT.x}%`, top: SPOT.y + FILE_H / 2 - 4 }}\n              aria-hidden=\"true\"\n            />\n            <button\n              key={active.id}\n              type=\"button\"\n              disabled={disabled}\n              aria-label={`Take the penalty with ${active.file.name}. Click a spot in the goal to aim, or press Enter to pick a corner at random.`}\n              onClick={() =>\n                kick(\n                  Math.random() < 0.5 ? 0.14 : 0.86,\n                  0.3 + Math.random() * 0.5,\n                )\n              }\n              className=\"absolute z-30 rounded-md outline-offset-4 outline-blue-600 ease-out focus-visible:outline-2 motion-reduce:transition-none\"\n              style={{\n                width: FILE_W,\n                height: FILE_H,\n                left: `${ball.x}%`,\n                top: ball.y,\n                transform: `translate(-50%, -50%) scale(${ball.scale}) rotate(${ball.turn}deg)`,\n                transitionProperty: 'left, top, transform',\n                transitionDuration: `${ball.ms}ms`,\n              }}\n            >\n              <FileIcon\n                name={active.file.name}\n                className={cn(\n                  'transition-[scale,opacity] duration-500',\n                  mood === 'done' && 'scale-50 opacity-0',\n                )}\n              />\n            </button>\n            <div\n              className={cn(\n                'pointer-events-none absolute inset-x-0 mx-auto w-[160px] text-center transition-opacity duration-200',\n                stage === 'ready' ? 'opacity-100' : 'opacity-0',\n              )}\n              style={{ top: GOAL_LINE + AREA_H + 3 }}\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          </>\n        ) : (\n          <>\n            {/* The picker is the penalty spot itself, with the + on its centre. */}\n            <button\n              type=\"button\"\n              onClick={browse}\n              disabled={disabled}\n              aria-label=\"Pick a file\"\n              className=\"group/spot absolute flex h-12 w-28 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-[100%] text-muted-foreground outline-offset-2 outline-blue-600 transition-colors hover:text-foreground focus-visible:outline-2\"\n              style={{ left: '50%', top: MARK_Y }}\n            >\n              <span\n                className=\"flex items-center justify-center rounded-[100%] border border-dashed border-muted-foreground/60 transition-colors group-hover/spot:border-foreground/70\"\n                style={MARK}\n              >\n                <Plus className=\"size-4\" aria-hidden=\"true\" />\n              </span>\n            </button>\n            <div\n              className=\"pointer-events-none absolute inset-x-0 text-center text-xs font-medium leading-4 text-muted-foreground\"\n              style={{ top: GOAL_LINE + AREA_H + 6 }}\n            >\n              Pick a file\n            </div>\n          </>\n        )}\n      </div>\n\n      <div\n        data-slot=\"penalty-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          {results.length > 0\n            ? `${made}/${results.length} scored`\n            : 'No kicks yet'}\n        </span>\n        <span className=\"flex items-center gap-3\">\n          {attempts >= 3 && canKick && !keeperGone ? (\n            <button\n              type=\"button\"\n              onClick={() => setKeeperGone(true)}\n              className=\"underline underline-offset-4 hover:text-foreground\"\n            >\n              Send the keeper home\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=\"penalty-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"
}