{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "response-feedback",
  "type": "registry:ui",
  "title": "Response Feedback",
  "description": "Thumbs that nod when pressed; a thumbs down unfolds reasons, a comment and send.",
  "categories": [
    "ai-agents"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/response-feedback.tsx",
      "content": "\"use client\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { X } from \"@/lib/icons\";\nimport { ease, spring, stagger } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type FeedbackRating = \"up\" | \"down\" | null;\nexport type FeedbackDetails = { rating: \"down\"; reasons: string[]; comment: string };\n\nexport type ResponseFeedbackProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onSubmit\"> & {\n  value?: FeedbackRating;\n  defaultValue?: FeedbackRating;\n  /** Fires the moment a thumb is pressed or released: save the rating here. */\n  onValueChange?: (value: FeedbackRating) => void;\n  /** Reasons offered after a thumbs down. An empty list keeps just the comment. */\n  reasons?: string[];\n  /** Sends the details. Return a promise to show progress; a rejection keeps the form and says so. */\n  onSubmit?: (details: FeedbackDetails) => void | Promise<void>;\n  label?: string;\n  disabled?: boolean;\n};\n\ntype Phase = \"closed\" | \"open\" | \"sending\" | \"sent\" | \"failed\";\n\nconst DEFAULT_REASONS = [\"Not accurate\", \"Didn’t follow instructions\", \"Out of date\", \"Too long\", \"Missing sources\", \"Unsafe\"];\n\nconst UP =\n  \"M5 7.25v6H3a.75.75 0 0 1-.75-.75V8A.75.75 0 0 1 3 7.25zm0 0 2.5-4.5a1.4 1.4 0 0 1 1.9 1.3l-.4 2.2h3.3a1.25 1.25 0 0 1 1.2 1.6l-1.3 4.3a1.25 1.25 0 0 1-1.2.85H5\";\nconst DOWN =\n  \"M11 8.75v-6h2a.75.75 0 0 1 .75.75V8a.75.75 0 0 1-.75.75zm0 0-2.5 4.5a1.4 1.4 0 0 1-1.9-1.3l.4-2.2H3.7a1.25 1.25 0 0 1-1.2-1.6l1.3-4.3a1.25 1.25 0 0 1 1.2-.85H11\";\n\nconst noop = () => () => {};\n/** ⌘ on Apple platforms, Ctrl elsewhere; ⌘ on the server so the first paint matches most readers. */\nconst useModKey = () => useSyncExternalStore(noop, () => (/Mac|iPhone|iPad/.test(navigator.platform) ? \"⌘\" : \"Ctrl\"), () => \"⌘\");\n\nconst ring = \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\";\n\nexport function ResponseFeedback({\n  value,\n  defaultValue = null,\n  onValueChange,\n  reasons = DEFAULT_REASONS,\n  onSubmit,\n  label = \"Was this helpful?\",\n  disabled = false,\n  className,\n  ...rest\n}: ResponseFeedbackProps) {\n  const reduce = !!useReducedMotion();\n  const id = useId();\n  const mod = useModKey();\n  const [rating, setRating] = useControllableState<FeedbackRating>({ value, defaultValue, onChange: onValueChange });\n  const [phase, setPhase] = useState<Phase>(\"closed\");\n  const [picked, setPicked] = useState<string[]>([]);\n  const [comment, setComment] = useState(\"\");\n  const downRef = useRef<HTMLButtonElement>(null);\n  const card = useRef<HTMLFormElement>(null);\n  const keyboard = useRef(false);\n  const focusOnOpen = useRef(false);\n  const closeTimer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(closeTimer.current), []);\n\n  const open = phase !== \"closed\";\n  const empty = picked.length === 0 && comment.trim() === \"\";\n\n  const focus = (el: HTMLElement | null | undefined) => el?.focus({ preventScroll: true, focusVisible: keyboard.current } as FocusOptions);\n\n  // The card's first reason gets focus once it exists, so the keyboard lands where the next decision is.\n  useEffect(() => {\n    if (phase !== \"open\" || !focusOnOpen.current) return;\n    // One frame later, once the reason group has registered its items, so arrow keys pick up from here.\n    // A render in between cancels and reschedules it; the flag only clears once it lands.\n    const raf = requestAnimationFrame(() => {\n      focusOnOpen.current = false;\n      focus(card.current?.querySelector<HTMLElement>(\"[data-reason], textarea\"));\n    });\n    return () => cancelAnimationFrame(raf);\n  });\n\n  const close = (returnFocus: boolean) => {\n    window.clearTimeout(closeTimer.current);\n    setPhase(\"closed\");\n    if (returnFocus) focus(downRef.current);\n  };\n\n  const rate = (next: FeedbackRating) => {\n    window.clearTimeout(closeTimer.current);\n    setRating(next);\n    if (next === \"down\") {\n      setPicked([]);\n      setComment(\"\");\n      focusOnOpen.current = true;\n      setPhase(\"open\");\n    } else setPhase(\"closed\");\n  };\n\n  const send = async () => {\n    if (empty || phase === \"sending\") return;\n    setPhase(\"sending\");\n    const started = performance.now();\n    try {\n      await onSubmit?.({ rating: \"down\", reasons: picked, comment: comment.trim() });\n      // A spinner that flashes for a frame reads as a glitch; hold it for 300ms.\n      await new Promise((r) => window.setTimeout(r, Math.max(0, 300 - (performance.now() - started))));\n      setPhase(\"sent\");\n      focus(downRef.current);\n      closeTimer.current = window.setTimeout(() => setPhase(\"closed\"), 2600);\n    } catch {\n      setPhase(\"failed\");\n    }\n  };\n\n  const status = rating === \"up\" ? \"Thanks for letting us know\" : phase === \"sent\" || (rating === \"down\" && phase === \"closed\") ? \"Thanks, feedback noted\" : label;\n  const labels = [label, \"Thanks for letting us know\", \"Thanks, feedback noted\"];\n\n  return (\n    <div className={cn(\"flex w-full flex-col\", className)} {...rest}>\n      <div className=\"flex items-center gap-2\">\n        <ToggleGroup\n          aria-label=\"Rate this response\"\n          value={rating ? [rating] : []}\n          onValueChange={(v) => rate((v[0] as FeedbackRating) ?? null)}\n          disabled={disabled}\n          className=\"-ml-1 flex items-center gap-0.5\"\n        >\n          <Thumb dir=\"up\" pressed={rating === \"up\"} reduce={reduce} onPointerDown={() => (keyboard.current = false)} onKeyDown={() => (keyboard.current = true)} />\n          <Thumb\n            dir=\"down\"\n            ref={downRef}\n            pressed={rating === \"down\"}\n            reduce={reduce}\n            expanded={open}\n            controls={`${id}-card`}\n            onPointerDown={() => (keyboard.current = false)}\n            onKeyDown={() => (keyboard.current = true)}\n          />\n        </ToggleGroup>\n        {/* Every message shares one grid cell, so the row never reflows as it changes. */}\n        <span className=\"grid min-w-0 text-[12.5px] text-fg-3\">\n          {labels.map((l) => (\n            <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1 truncate\">\n              {l}\n            </span>\n          ))}\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={status}\n              className=\"col-start-1 row-start-1 truncate\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n              transition={{ duration: 0.22, ease: ease.out }}\n            >\n              {status}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {rating === \"up\" ? \"Marked as helpful\" : phase === \"sent\" ? \"Feedback sent\" : phase === \"failed\" ? \"Couldn’t send feedback\" : \"\"}\n        </span>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            key=\"card\"\n            initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.12 } }\n                : { height: 0, opacity: 0, transition: { height: { duration: 0.22, ease: ease.inOut }, opacity: { duration: 0.12 } } }\n            }\n            transition={{ height: { duration: 0.3, ease: ease.out }, opacity: { duration: 0.2, ease: ease.out } }}\n            // Padding inside the clip, cancelled by margin, so the card's shadow and focus rings aren't cut off.\n            className=\"-mx-3 -mb-5 overflow-hidden px-3 pb-5\"\n          >\n            {/* The card grows down from the row; its content settles a beat later so the height leads. */}\n            <form\n              ref={card}\n              id={`${id}-card`}\n              aria-labelledby={`${id}-title`}\n              onSubmit={(e) => {\n                e.preventDefault();\n                void send();\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Escape\") {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  keyboard.current = true;\n                  close(true);\n                }\n              }}\n              className=\"mt-2 max-w-md rounded-xl border border-line-2 bg-raised shadow-[var(--shadow)]\"\n            >\n              <Morph reduce={reduce}>\n                <AnimatePresence initial={false} mode=\"wait\">\n                  {phase === \"sent\" ? (\n                    <motion.div\n                      key=\"sent\"\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98, filter: \"blur(2px)\" }}\n                      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                      transition={{ duration: 0.24, ease: ease.out }}\n                      className=\"flex items-center gap-2.5 px-3.5 py-3\"\n                    >\n                      <SentMark reduce={reduce} />\n                      <div className=\"min-w-0\">\n                        <p id={`${id}-title`} className=\"text-[13px] font-medium tracking-[-0.01em] text-fg\">\n                          Feedback sent\n                        </p>\n                        <p className=\"text-[12px] text-fg-3\">It goes to the team that tunes these answers.</p>\n                      </div>\n                    </motion.div>\n                  ) : (\n                    <motion.div\n                      key=\"form\"\n                      initial={false}\n                      exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.99, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n                      className=\"p-3.5\"\n                      aria-busy={phase === \"sending\" || undefined}\n                    >\n                      <div className=\"flex items-start justify-between gap-3\">\n                        <div className=\"min-w-0\">\n                          <p id={`${id}-title`} className=\"text-[13px] font-medium tracking-[-0.01em] text-fg\">\n                            What went wrong?\n                          </p>\n                          <p className=\"mt-0.5 text-[12px] text-fg-3\">Pick any that apply.</p>\n                        </div>\n                        <button\n                          type=\"button\"\n                          aria-label=\"Skip feedback\"\n                          onClick={(e) => {\n                            keyboard.current = e.detail === 0;\n                            close(true);\n                          }}\n                          className={cn(\n                            ring,\n                            \"relative -mr-1.5 -mt-1.5 grid size-7 shrink-0 place-items-center rounded-md text-fg-3 transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92]\",\n                            \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n                          )}\n                        >\n                          <X size={14} />\n                        </button>\n                      </div>\n\n                      {reasons.length > 0 && (\n                        <ToggleGroup\n                          multiple\n                          aria-label=\"Reasons\"\n                          value={picked}\n                          onValueChange={(v) => setPicked(v as string[])}\n                          disabled={phase === \"sending\"}\n                          className=\"mt-3 flex flex-wrap gap-1.5\"\n                        >\n                          {reasons.map((r, i) => (\n                            <motion.span\n                              key={r}\n                              className=\"flex\"\n                              initial={reduce ? false : { opacity: 0, y: 4 }}\n                              animate={{ opacity: 1, y: 0 }}\n                              transition={{ duration: 0.22, ease: ease.out, delay: 0.08 + Math.min(i, 8) * stagger.items }}\n                            >\n                              <Reason value={r} pressed={picked.includes(r)} reduce={reduce} />\n                            </motion.span>\n                          ))}\n                        </ToggleGroup>\n                      )}\n\n                      <label htmlFor={`${id}-comment`} className=\"mt-3 block text-[12px] text-fg-2\">\n                        Anything else? <span className=\"text-fg-4\">Optional</span>\n                      </label>\n                      <textarea\n                        id={`${id}-comment`}\n                        value={comment}\n                        onChange={(e) => setComment(e.target.value)}\n                        onKeyDown={(e) => {\n                          if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n                            e.preventDefault();\n                            void send();\n                          }\n                        }}\n                        readOnly={phase === \"sending\"}\n                        rows={2}\n                        maxLength={1000}\n                        placeholder=\"The date it gave for the launch was last year’s\"\n                        className={cn(\n                          \"mt-1.5 block max-h-32 min-h-[60px] w-full resize-none rounded-lg border border-line-2 bg-frame px-2.5 py-2 text-base leading-[1.45] text-fg [field-sizing:content] placeholder:text-fg-4 sm:text-[13px]\",\n                          \"outline-none transition-[border-color,box-shadow] duration-150 focus:border-fg-4 focus:ring-3 focus:ring-fg/10\",\n                        )}\n                      />\n\n                      <div className=\"mt-3 flex items-center gap-2\">\n                        <span className=\"min-w-0 flex-1 text-[11.5px]\">\n                          {phase === \"failed\" ? (\n                            <span className=\"text-danger\">Couldn’t send. Check your connection and try again.</span>\n                          ) : (\n                            <span className=\"hidden text-fg-4 pointer-fine:inline\">\n                              <kbd className=\"font-sans\">{mod}</kbd> <kbd className=\"font-sans\">Enter</kbd> to send\n                            </span>\n                          )}\n                        </span>\n                        <button\n                          type=\"button\"\n                          onClick={(e) => {\n                            keyboard.current = e.detail === 0;\n                            close(true);\n                          }}\n                          className={cn(\n                            ring,\n                            \"h-8 shrink-0 rounded-lg px-2.5 text-[12.5px] font-medium text-fg-2 transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.97]\",\n                          )}\n                        >\n                          Skip\n                        </button>\n                        <button\n                          type=\"submit\"\n                          aria-disabled={empty || undefined}\n                          data-busy={phase === \"sending\" ? \"\" : undefined}\n                          className={cn(\n                            ring,\n                            \"relative h-8 shrink-0 rounded-lg bg-fg px-3 text-[12.5px] font-medium text-frame transition-[background-color,opacity,scale] duration-150 hover:bg-fg/90 active:scale-[0.97]\",\n                            \"aria-disabled:cursor-not-allowed aria-disabled:opacity-40 aria-disabled:hover:bg-fg aria-disabled:active:scale-100\",\n                            \"data-busy:pointer-events-none\",\n                          )}\n                        >\n                          {/* The spinner overlays the label, so the button keeps its width. */}\n                          <span className={cn(\"transition-opacity duration-150\", phase === \"sending\" && \"opacity-0\")}>\n                            {phase === \"failed\" ? \"Try again\" : \"Send feedback\"}\n                          </span>\n                          <span\n                            className={cn(\"absolute inset-0 grid place-items-center transition-opacity duration-150\", phase === \"sending\" ? \"opacity-100\" : \"opacity-0\")}\n                            aria-hidden\n                          >\n                            <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" className=\"animate-spin-slow motion-reduce:animate-none\">\n                              <circle cx=\"8\" cy=\"8\" r=\"5.5\" stroke=\"currentColor\" strokeOpacity={0.3} strokeWidth={1.6} />\n                              <path d=\"M8 2.5a5.5 5.5 0 0 1 5.5 5.5\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" />\n                            </svg>\n                          </span>\n                        </button>\n                      </div>\n                    </motion.div>\n                  )}\n                </AnimatePresence>\n              </Morph>\n            </form>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/** Animates its own height to whatever its content measures, so swapping the form for the thanks glides instead of jumping. */\nfunction Morph({ reduce, children }: { reduce: boolean; children: React.ReactNode }) {\n  const inner = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return (\n    <motion.div initial={false} animate={{ height }} transition={reduce ? { duration: 0 } : { duration: 0.28, ease: ease.inOut }} className=\"overflow-hidden\">\n      <div ref={inner}>{children}</div>\n    </motion.div>\n  );\n}\n\nfunction Thumb({\n  dir,\n  pressed,\n  reduce,\n  expanded,\n  controls,\n  ref,\n  ...rest\n}: {\n  dir: \"up\" | \"down\";\n  pressed: boolean;\n  reduce: boolean;\n  expanded?: boolean;\n  controls?: string;\n  ref?: React.Ref<HTMLButtonElement>;\n  onPointerDown?: () => void;\n  onKeyDown?: () => void;\n}) {\n  const [scope, animate] = useAnimate<HTMLSpanElement>();\n  const name = dir === \"up\" ? \"Helpful\" : \"Not helpful\";\n\n  // The acknowledgement: a small nod in the thumb's own direction as it fills.\n  const mounted = useRef(false);\n  useEffect(() => {\n    // Only a change nods; a thumb that starts pressed just sits there.\n    if (!mounted.current) return void (mounted.current = true);\n    if (!pressed || reduce || !scope.current) return;\n    animate(scope.current, { rotate: [0, dir === \"up\" ? -16 : 16, 0], y: [0, dir === \"up\" ? -2 : 2, 0], scale: [1, 0.88, 1] }, { duration: 0.44, ease: ease.out });\n  }, [pressed, reduce, dir, animate, scope]);\n\n  return (\n    <Toggle\n      ref={ref}\n      value={dir}\n      aria-label={name}\n      title={name}\n      aria-expanded={dir === \"down\" ? !!expanded : undefined}\n      aria-controls={dir === \"down\" && expanded ? controls : undefined}\n      className={cn(\n        ring,\n        \"relative grid size-7 place-items-center rounded-md text-fg-3 transition-[background-color,color,scale] duration-150 ease-out\",\n        \"hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75 data-pressed:text-fg data-disabled:pointer-events-none data-disabled:opacity-50\",\n        \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n      )}\n      {...rest}\n    >\n      <span ref={scope} className=\"grid size-4 place-items-center\">\n        <svg width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n          <motion.path\n            d={dir === \"up\" ? UP : DOWN}\n            fill=\"currentColor\"\n            initial={false}\n            animate={{ fillOpacity: pressed ? 1 : 0 }}\n            transition={{ duration: pressed ? 0.2 : 0.14, ease: ease.out, delay: pressed && !reduce ? 0.08 : 0 }}\n          />\n        </svg>\n      </span>\n    </Toggle>\n  );\n}\n\nfunction Reason({ value, pressed, reduce }: { value: string; pressed: boolean; reduce: boolean }) {\n  return (\n    <Toggle\n      value={value}\n      data-reason=\"\"\n      className={cn(\n        ring,\n        \"relative inline-flex h-7 select-none items-center rounded-full border px-2.5 text-[12px] transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.96] active:duration-75\",\n        \"border-line-2 text-fg-2 hover:border-fg-4 hover:text-fg data-pressed:border-fg-4 data-pressed:bg-fg/[0.07] data-pressed:text-fg data-disabled:opacity-50\",\n      )}\n    >\n      {/* The tick makes room for itself: its column grows from nothing while it draws. */}\n      <span\n        className={cn(\n          \"grid transition-[grid-template-columns] duration-200 ease-out-expo motion-reduce:transition-none\",\n          pressed ? \"grid-cols-[1fr]\" : \"grid-cols-[0fr]\",\n        )}\n      >\n        <span className=\"overflow-hidden\">\n          <svg\n            width={12}\n            height={12}\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.8}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden\n            className=\"-ml-0.5 mr-1\"\n          >\n            <motion.path\n              d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n              initial={false}\n              animate={{ pathLength: pressed ? 1 : 0, opacity: pressed ? 1 : 0 }}\n              transition={{ duration: reduce ? 0 : 0.26, ease: ease.out, delay: pressed && !reduce ? 0.06 : 0 }}\n            />\n          </svg>\n        </span>\n      </span>\n      {value}\n    </Toggle>\n  );\n}\n\nfunction SentMark({ reduce }: { reduce: boolean }) {\n  return (\n    <motion.span\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n      animate={{ opacity: 1, scale: 1 }}\n      transition={reduce ? { duration: 0.15 } : spring.pop}\n      className=\"grid size-7 shrink-0 place-items-center rounded-full bg-success-soft text-success\"\n    >\n      <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n        <motion.path\n          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n          initial={reduce ? false : { pathLength: 0 }}\n          animate={{ pathLength: 1 }}\n          transition={{ duration: 0.34, ease: ease.out, delay: 0.1 }}\n        />\n      </svg>\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/response-feedback"
}
