{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"mood-meter","type":"registry:ui","title":"MoodMeter","description":"MoodMeter from react-fancy","package":"react-fancy","dependencies":[],"registryDependencies":[],"files":[{"path":"components\/fancy\/mood-meter\/MoodMeter.tsx","content":"import { useCallback, useRef, useState } from \"react\";\r\n\r\n\/**\r\n * MoodMeter \u2014 a 2D draggable pad that captures a value AND the\r\n * confidence in that value on the same handle.\r\n *\r\n *   <MoodMeter\r\n *     min={0} max={1}\r\n *     value={value} confidence={confidence}\r\n *     onChange={(v, c) => { setValue(v); setConfidence(c); }}\r\n *   \/>\r\n *\r\n *   \u2022 x-axis maps to [min..max]\r\n *   \u2022 y-axis maps to [0..1] confidence (top = sure)\r\n *   \u2022 the halo around the handle scales inversely with confidence \u2014\r\n *     uncertain readings literally look fuzzier\r\n *\r\n * Why pair them in one control? When AIs post a number, humans\r\n * immediately ask \"how sure are you?\". Making confidence an explicit\r\n * sibling of value \u2014 and letting the user drag either independently \u2014\r\n * turns vague \"AI suggestion\" UX into something you can argue with\r\n * precisely.\r\n *\r\n * The agent's posted value\/confidence renders as a dashed ghost\r\n * handle alongside the live user handle, so the human can see where\r\n * the agent landed and how far they've drifted.\r\n *\/\r\nexport interface MoodMeterProps {\r\n  \/** Range minimum. *\/\r\n  min: number;\r\n  \/** Range maximum. *\/\r\n  max: number;\r\n  \/** Step for value snapping. Defaults to (max-min)\/100. *\/\r\n  step?: number;\r\n  \/** Current value (controlled). *\/\r\n  value: number;\r\n  \/** Current confidence 0..1 (controlled). *\/\r\n  confidence: number;\r\n  \/** Called with (value, confidence) on drag \/ pointer events. *\/\r\n  onChange: (value: number, confidence: number) => void;\r\n  \/** Optional agent post \u2014 renders as a dashed ghost handle. *\/\r\n  posted?: { value: number; confidence: number };\r\n  \/** Pixel width of the pad. Defaults to 320. *\/\r\n  width?: number;\r\n  \/** Pixel height of the pad. Defaults to 220. *\/\r\n  height?: number;\r\n  \/** Show the grid + axis labels. Defaults to true. *\/\r\n  showGrid?: boolean;\r\n  \/** Color of the user handle. Defaults to sky blue. *\/\r\n  color?: string;\r\n  \/** Color of the posted-ghost handle. Defaults to violet. *\/\r\n  postedColor?: string;\r\n  \/** Optional prefix for the value label (e.g. \"$\"). *\/\r\n  prefix?: string;\r\n  \/** Optional suffix for the value label (e.g. \"k\", \"%\"). *\/\r\n  suffix?: string;\r\n  \/** Optional label text override. Falls back to formatted value. *\/\r\n  formatValue?: (v: number) => string;\r\n  \/** Optional className applied to the outer pad. *\/\r\n  className?: string;\r\n}\r\n\r\nexport function MoodMeter({\r\n  min,\r\n  max,\r\n  step,\r\n  value,\r\n  confidence,\r\n  onChange,\r\n  posted,\r\n  width = 320,\r\n  height = 220,\r\n  showGrid = true,\r\n  color = \"#0ea5e9\",\r\n  postedColor = \"#a855f7\",\r\n  prefix = \"\",\r\n  suffix = \"\",\r\n  formatValue,\r\n  className,\r\n}: MoodMeterProps) {\r\n  const ref = useRef<HTMLDivElement>(null);\r\n  const [dragging, setDragging] = useState(false);\r\n\r\n  const snapStep = step ?? (max - min) \/ 100;\r\n\r\n  const fmt = useCallback(\r\n    (v: number) => {\r\n      if (formatValue) return formatValue(v);\r\n      const num = snapStep < 1 ? v.toFixed(2) : Math.round(v).toString();\r\n      return `${prefix}${num}${suffix}`;\r\n    },\r\n    [formatValue, snapStep, prefix, suffix],\r\n  );\r\n\r\n  const set = useCallback(\r\n    (clientX: number, clientY: number) => {\r\n      const el = ref.current;\r\n      if (!el) return;\r\n      const rect = el.getBoundingClientRect();\r\n      const x = clamp((clientX - rect.left) \/ rect.width, 0, 1);\r\n      const y = clamp((clientY - rect.top) \/ rect.height, 0, 1);\r\n      const raw = min + x * (max - min);\r\n      const snapped = round(raw, snapStep, min);\r\n      const c = clamp(1 - y, 0, 1);\r\n      onChange(snapped, Math.round(c * 100) \/ 100);\r\n    },\r\n    [min, max, snapStep, onChange],\r\n  );\r\n\r\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\r\n    (e.target as Element).setPointerCapture?.(e.pointerId);\r\n    setDragging(true);\r\n    set(e.clientX, e.clientY);\r\n  };\r\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\r\n    if (!dragging) return;\r\n    set(e.clientX, e.clientY);\r\n  };\r\n  const onPointerUp = () => setDragging(false);\r\n\r\n  const xPct = ((value - min) \/ (max - min)) * 100;\r\n  const yPct = (1 - confidence) * 100;\r\n  const haloR = 18 + (1 - confidence) * 70;\r\n\r\n  const pxPct = posted ? ((posted.value - min) \/ (max - min)) * 100 : 0;\r\n  const pyPct = posted ? (1 - posted.confidence) * 100 : 0;\r\n  const pHaloR = posted ? 16 + (1 - posted.confidence) * 60 : 0;\r\n\r\n  return (\r\n    <div\r\n      ref={ref}\r\n      onPointerDown={onPointerDown}\r\n      onPointerMove={onPointerMove}\r\n      onPointerUp={onPointerUp}\r\n      onPointerCancel={onPointerUp}\r\n      className={`relative cursor-crosshair touch-none select-none overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950 ${className ?? \"\"}`}\r\n      style={{ width, height }}\r\n    >\r\n      {showGrid && (\r\n        <svg className=\"pointer-events-none absolute inset-0 h-full w-full\" aria-hidden>\r\n          {[0.25, 0.5, 0.75].map((p, i) => (\r\n            <line\r\n              key={`v${i}`}\r\n              x1={`${p * 100}%`}\r\n              x2={`${p * 100}%`}\r\n              y1=\"0\"\r\n              y2=\"100%\"\r\n              stroke=\"#a1a1aa\"\r\n              strokeOpacity={0.18}\r\n              strokeDasharray=\"3 4\"\r\n            \/>\r\n          ))}\r\n          {[0.25, 0.5, 0.75].map((p, i) => (\r\n            <line\r\n              key={`h${i}`}\r\n              x1=\"0\"\r\n              x2=\"100%\"\r\n              y1={`${p * 100}%`}\r\n              y2={`${p * 100}%`}\r\n              stroke=\"#a1a1aa\"\r\n              strokeOpacity={0.18}\r\n              strokeDasharray=\"3 4\"\r\n            \/>\r\n          ))}\r\n        <\/svg>\r\n      )}\r\n\r\n      {showGrid && (\r\n        <>\r\n          <div className=\"pointer-events-none absolute left-1.5 top-1.5 text-[10px] uppercase tracking-wider text-zinc-400\">\r\n            \u2191 sure\r\n          <\/div>\r\n          <div className=\"pointer-events-none absolute bottom-1.5 left-1.5 text-[10px] uppercase tracking-wider text-zinc-400\">\r\n            \u2193 unsure\r\n          <\/div>\r\n          <div className=\"pointer-events-none absolute right-1.5 top-1.5 text-[10px] uppercase tracking-wider text-zinc-400\">\r\n            {fmt(max)} \u2192\r\n          <\/div>\r\n          <div className=\"pointer-events-none absolute bottom-1.5 right-1.5 text-[10px] uppercase tracking-wider text-zinc-400\">\r\n            \u2190 {fmt(min)}\r\n          <\/div>\r\n        <\/>\r\n      )}\r\n\r\n      {posted && (\r\n        <Handle\r\n          xPct={pxPct}\r\n          yPct={pyPct}\r\n          haloR={pHaloR}\r\n          color={postedColor}\r\n          dashed\r\n          label=\"agent\"\r\n        \/>\r\n      )}\r\n      <Handle\r\n        xPct={xPct}\r\n        yPct={yPct}\r\n        haloR={haloR}\r\n        color={color}\r\n        label={fmt(value)}\r\n      \/>\r\n    <\/div>\r\n  );\r\n}\r\n\r\nfunction Handle({\r\n  xPct,\r\n  yPct,\r\n  haloR,\r\n  color,\r\n  dashed = false,\r\n  label,\r\n}: {\r\n  xPct: number;\r\n  yPct: number;\r\n  haloR: number;\r\n  color: string;\r\n  dashed?: boolean;\r\n  label: string;\r\n}) {\r\n  return (\r\n    <>\r\n      <div\r\n        className=\"pointer-events-none absolute -translate-x-1\/2 -translate-y-1\/2 rounded-full\"\r\n        style={{\r\n          left: `${xPct}%`,\r\n          top: `${yPct}%`,\r\n          width: haloR * 2,\r\n          height: haloR * 2,\r\n          background: color + (dashed ? \"11\" : \"22\"),\r\n          border: dashed ? `1px dashed ${color}` : \"none\",\r\n        }}\r\n      \/>\r\n      <div\r\n        className=\"pointer-events-none absolute -translate-x-1\/2 -translate-y-1\/2 rounded-full ring-2 ring-white dark:ring-zinc-900\"\r\n        style={{\r\n          left: `${xPct}%`,\r\n          top: `${yPct}%`,\r\n          width: 14,\r\n          height: 14,\r\n          background: color,\r\n          opacity: dashed ? 0.6 : 1,\r\n        }}\r\n      \/>\r\n      <div\r\n        className=\"pointer-events-none absolute -translate-x-1\/2 rounded px-1.5 py-0.5 font-mono text-[10px]\"\r\n        style={{\r\n          left: `${xPct}%`,\r\n          top: `calc(${yPct}% + 14px)`,\r\n          color,\r\n        }}\r\n      >\r\n        {label}\r\n      <\/div>\r\n    <\/>\r\n  );\r\n}\r\n\r\nfunction clamp(n: number, lo: number, hi: number) {\r\n  return Math.max(lo, Math.min(hi, n));\r\n}\r\n\r\nfunction round(n: number, step: number, min: number) {\r\n  if (step >= 1) return Math.round(n);\r\n  return Math.round((n - min) \/ step) * step + min;\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/mood-meter\/MoodMeter.tsx"},{"path":"components\/fancy\/mood-meter\/index.ts","content":"export { MoodMeter } from \".\/MoodMeter\";\r\nexport type { MoodMeterProps } from \".\/MoodMeter\";\r\n","type":"registry:ui","target":"components\/fancy\/mood-meter\/index.ts"}]}