{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "DodgeField-JS-TW",
	"title": "DodgeField",
	"description": "Wrapper that makes any child flee the pointer inside a bounded field, dodges once per approach, then relents after a few tries and glides home.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "DodgeField/DodgeField.jsx",
			"content": "'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { animate, motion, useMotionTemplate, useMotionValue, useReducedMotion } from 'motion/react';\n\nconst COUNT_LINE = 0.55;\nconst DEAD_ZONE = 6;\nconst CAUGHT_HOLD_MS = 760;\nconst INSET = 12;\nconst DEFAULT_TAUNTS = ['Catch me', 'Nope', 'Too slow', 'Almost', 'Okay, okay'];\n\nconst wallIt = (t, room, wall) => {\n  if (wall === 'bounce') {\n    if (t > room) return Math.max(-room, 2 * room - t);\n    if (t < -room) return Math.min(room, -2 * room - t);\n    return t;\n  }\n  return Math.min(room, Math.max(-room, t));\n};\nconst bearingOf = (dx, dy, d, axis) => {\n  if (axis === 'x') return { x: Math.sign(dx) || 1, y: 0 };\n  if (axis === 'y') return { x: 0, y: Math.sign(dy) || 1 };\n  return { x: dx / d, y: dy / d };\n};\n\nexport default function DodgeField({\n  children,\n  taunts = DEFAULT_TAUNTS,\n  notice = '',\n  inkColor = '#f5f5f5',\n  contrastColor = '#18181b',\n  fieldHeight = 240,\n  reach = 72,\n  radius = 120,\n  falloff = 2,\n  fleeDuration = 130,\n  returnDuration = 620,\n  returnBounce = 0.1,\n  axis = 'both',\n  wall = 'clamp',\n  patience = 4,\n  disabled = false,\n  onDodge,\n  onRelent,\n  onCatch,\n  className = '',\n  style\n}) {\n  const fieldRef = useRef(null);\n  const moverRef = useRef(null);\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const transform = useMotionTemplate`translate(${x}px, ${y}px)`;\n  const reduce = useReducedMotion();\n  const [fine, setFine] = useState(true);\n  const [inside, setInside] = useState(false);\n  const [dodges, setDodges] = useState(0);\n  const [caught, setCaught] = useState(false);\n  const gave = dodges >= Math.max(1, patience);\n  const still = gave || caught || disabled || !!reduce;\n\n  const pointer = useRef(null);\n  const bearing = useRef({ x: 1, y: 0 });\n  const armed = useRef(true);\n  const room = useRef({ x: 0, y: 0 });\n  const raf = useRef(0);\n  const hold = useRef(undefined);\n  const live = useRef({});\n  live.current = {\n    reach,\n    radius,\n    falloff,\n    fleeDuration,\n    returnDuration,\n    returnBounce,\n    axis,\n    wall,\n    still,\n    inside,\n    reduce\n  };\n\n  useEffect(() => {\n    const field = fieldRef.current;\n    const mover = moverRef.current;\n    if (!field || !mover) return undefined;\n    const measure = () => {\n      room.current = {\n        x: Math.max(0, (field.clientWidth - mover.offsetWidth) / 2 - INSET),\n        y: Math.max(0, (field.clientHeight - mover.offsetHeight) / 2 - INSET)\n      };\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(field);\n    observer.observe(mover);\n    return () => observer.disconnect();\n  }, []);\n\n  const frame = useCallback(() => {\n    raf.current = 0;\n    const field = fieldRef.current;\n    const p = pointer.current;\n    const L = live.current;\n    if (!field) return;\n    const rect = field.getBoundingClientRect();\n    const zoom = rect.width / (field.offsetWidth || rect.width) || 1;\n    const dx = p ? (p.x - (rect.left + rect.width / 2)) / zoom : Infinity;\n    const dy = p ? (p.y - (rect.top + rect.height / 2)) / zoom : Infinity;\n    const d = Math.hypot(dx, dy);\n    const isInside = d <= L.radius;\n    if (!isInside && !L.inside) return;\n    if (!L.still) {\n      if (d < L.radius * COUNT_LINE) {\n        if (armed.current) {\n          armed.current = false;\n          setDodges(n => n + 1);\n        }\n      } else if (d > L.radius) {\n        armed.current = true;\n      }\n    }\n    if (Number.isFinite(d) && d > DEAD_ZONE) bearing.current = bearingOf(dx, dy, d, L.axis);\n    const flee = isInside && !L.still ? (1 - d / L.radius) ** L.falloff : 0;\n    const tx = wallIt(-bearing.current.x * flee * L.reach, room.current.x, L.wall);\n    const ty = wallIt(-bearing.current.y * flee * L.reach, room.current.y, L.wall);\n    if (L.reduce) {\n      x.jump(0);\n      y.jump(0);\n    } else {\n      const cfg =\n        flee > 0\n          ? { type: 'spring', duration: L.fleeDuration / 1000, bounce: 0 }\n          : { type: 'spring', duration: L.returnDuration / 1000, bounce: L.returnBounce };\n      animate(x, tx, cfg);\n      animate(y, ty, cfg);\n    }\n    if (isInside !== L.inside) setInside(isInside);\n  }, [x, y]);\n\n  useEffect(() => {\n    const query = window.matchMedia('(hover: hover) and (pointer: fine)');\n    const sync = () => setFine(query.matches);\n    sync();\n    query.addEventListener('change', sync);\n    if (!query.matches || disabled) return () => query.removeEventListener('change', sync);\n    const tick = () => {\n      if (!raf.current) raf.current = requestAnimationFrame(frame);\n    };\n    const onMove = e => {\n      if (e.pointerType === 'touch') return;\n      pointer.current = { x: e.clientX, y: e.clientY };\n      tick();\n    };\n    const onLeave = () => {\n      pointer.current = null;\n      tick();\n    };\n    window.addEventListener('pointermove', onMove, { passive: true });\n    window.addEventListener('scroll', tick, { passive: true, capture: true });\n    document.addEventListener('pointerleave', onLeave);\n    window.addEventListener('blur', onLeave);\n    return () => {\n      query.removeEventListener('change', sync);\n      window.removeEventListener('pointermove', onMove);\n      window.removeEventListener('scroll', tick, { capture: true });\n      document.removeEventListener('pointerleave', onLeave);\n      window.removeEventListener('blur', onLeave);\n      cancelAnimationFrame(raf.current);\n      raf.current = 0;\n    };\n  }, [disabled, frame]);\n\n  useEffect(() => {\n    if (!raf.current) raf.current = requestAnimationFrame(frame);\n  }, [still, frame]);\n  useEffect(() => {\n    if (dodges) onDodge?.(dodges);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [dodges]);\n  useEffect(() => {\n    if (gave) onRelent?.();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [gave]);\n  useEffect(() => () => clearTimeout(hold.current), []);\n\n  const handleClick = () => {\n    setCaught(true);\n    onCatch?.();\n    clearTimeout(hold.current);\n    hold.current = setTimeout(() => {\n      setCaught(false);\n      setDodges(0);\n      armed.current = false;\n    }, CAUGHT_HOLD_MS);\n  };\n\n  const state = { dodges, gave, caught, fleeing: inside && !still };\n  const index = gave || caught ? taunts.length - 1 : Math.min(dodges, Math.max(0, taunts.length - 2));\n  const content =\n    typeof children === 'function'\n      ? children(state)\n      : (children ?? (\n          <button\n            type=\"button\"\n            className=\"m-0 inline-flex h-[52px] cursor-pointer touch-manipulation items-center justify-center rounded-full border-0 px-7 text-[18px] leading-none font-medium tracking-[-0.012em] select-none [font-family:inherit] [-webkit-tap-highlight-color:transparent] [background:color-mix(in_srgb,var(--df-ink)_8%,transparent)] [color:color-mix(in_srgb,var(--df-ink)_72%,transparent)] [transition:transform_160ms_var(--df-ease-out),background-color_220ms_ease,color_220ms_ease] active:[transform:scale(0.97)] motion-reduce:active:[transform:none] motion-reduce:[transition:background-color_220ms_ease,color_220ms_ease] [@media(hover:hover)_and_(pointer:fine)]:hover:[color:color-mix(in_srgb,var(--df-ink)_90%,transparent)] group-data-[relented=true]:[color:var(--df-ink)] group-data-[relented=true]:hover:[color:var(--df-ink)] group-data-[caught=true]:[background:var(--df-ink)] group-data-[caught=true]:[color:var(--df-contrast)] group-data-[caught=true]:hover:[color:var(--df-contrast)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-[3px] focus-visible:[outline-color:color-mix(in_srgb,var(--df-ink)_60%,transparent)]\"\n            aria-label={taunts[0]}\n          >\n            <span className=\"grid\">\n              {taunts.map((taunt, i) => (\n                <span\n                  key={`${taunt}-${i}`}\n                  className=\"[grid-area:1/1] whitespace-nowrap [transition:opacity_200ms_ease,filter_200ms_ease] data-[active=false]:opacity-0 data-[active=false]:[filter:blur(2px)] motion-reduce:data-[active=false]:[filter:none]\"\n                  data-active={i === index ? 'true' : 'false'}\n                  aria-hidden=\"true\"\n                >\n                  {taunt}\n                </span>\n              ))}\n            </span>\n          </button>\n        ));\n\n  return (\n    <div\n      ref={fieldRef}\n      className={`relative grid w-full place-items-center [height:var(--df-height)]${className ? ` ${className}` : ''}`}\n      data-coarse={fine ? undefined : ''}\n      data-flat={reduce ? '' : undefined}\n      style={{\n        '--df-ink': inkColor,\n        '--df-contrast': contrastColor,\n        '--df-height': `${fieldHeight}px`,\n        '--df-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)',\n        ...style\n      }}\n    >\n      <motion.div\n        ref={moverRef}\n        className=\"group inline-grid [will-change:transform]\"\n        style={{ transform }}\n        data-fled={inside && !still ? 'true' : 'false'}\n        data-relented={gave ? 'true' : 'false'}\n        data-caught={caught ? 'true' : 'false'}\n        onClick={handleClick}\n      >\n        {content}\n      </motion.div>\n      {!fine && notice ? (\n        <p className=\"pointer-events-none absolute inset-x-0 bottom-3 m-0 text-center text-[12px] [color:color-mix(in_srgb,var(--df-ink)_55%,transparent)]\">\n          {notice}\n        </p>\n      ) : null}\n    </div>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"motion@^12.23.12"
	]
}