{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-actions",
  "type": "registry:ui",
  "title": "Message Actions",
  "description": "The row under a reply: copy draws a tick, regenerate turns, thumbs fill.",
  "categories": [
    "ai-chat"
  ],
  "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",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/message-actions.tsx",
      "content": "\"use client\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Toolbar } from \"@base-ui/react/toolbar\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useAnimate,\n  useMotionValue,\n  useReducedMotion,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from \"react\";\nimport { useCopy } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, Copy, MoreH, Refresh, Share, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Root\n * -----------------------------------------------------------------------------------------------*/\n\ntype Ctx = { announce: (message: string) => void };\nconst ActionsContext = createContext<Ctx>({ announce: () => {} });\n\nexport type MessageActionsProps = Omit<Toolbar.Root.Props, \"className\" | \"orientation\"> & {\n  className?: string;\n  /**\n   * `hover` keeps the row invisible until the message (any ancestor with `data-message`) or the\n   * row itself is hovered, or a control inside is focused from the keyboard. Touch screens always\n   * show it. `always` is for the latest reply.\n   */\n  reveal?: \"hover\" | \"always\";\n};\n\n/**\n * The row under a reply. A toolbar: one Tab stop, arrow keys move between actions, Home and End\n * jump to the ends. Its height is always reserved, so revealing it never moves the thread.\n */\nexport function MessageActions({ reveal = \"always\", className, children, \"aria-label\": ariaLabel = \"Message actions\", ...rest }: MessageActionsProps) {\n  const [message, setMessage] = useState(\"\");\n  const frame = useRef(0);\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n  // Clearing first lets the same sentence be announced twice in a row.\n  const announce = useCallback((next: string) => {\n    setMessage(\"\");\n    cancelAnimationFrame(frame.current);\n    frame.current = requestAnimationFrame(() => setMessage(next));\n  }, []);\n\n  return (\n    <ActionsContext value={{ announce }}>\n      {/* One warm-up for every tooltip in the row: the first waits, its neighbors open on contact. */}\n      <Tooltip.Provider delay={500} timeout={400}>\n        <Toolbar.Root\n          aria-label={ariaLabel}\n          data-mode={reveal}\n          className={cn(\n            \"relative flex min-h-7 items-center gap-0.5 pointer-coarse:min-h-9\",\n            // --reveal drives every action's opacity. Hidden only on fine pointers; the :where keeps the\n            // hiding rule weaker than every rule that reveals.\n            \"pointer-fine:[&:where([data-mode=hover])]:[--reveal:0]\",\n            \"[[data-message]:hover_&]:[--reveal:1] data-[mode=hover]:hover:[--reveal:1]\",\n            \"data-[mode=hover]:has-[:focus-visible]:[--reveal:1] data-[mode=hover]:has-[[data-popup-open]]:[--reveal:1]\",\n            className,\n          )}\n          {...rest}\n        >\n          {children}\n          <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n            {message}\n          </span>\n        </Toolbar.Root>\n      </Tooltip.Provider>\n    </ActionsContext>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Shared pieces: the button look and its tooltip\n * -----------------------------------------------------------------------------------------------*/\n\nconst buttonClass = cn(\n  \"relative inline-flex size-7 shrink-0 select-none items-center justify-center rounded-md text-fg-3 outline-none\",\n  \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n  \"opacity-[var(--reveal,1)] data-keep:opacity-100\",\n  \"transition-[opacity,background-color,color,scale] duration-150 ease-out active:scale-[0.9] active:duration-75\",\n  \"hover:bg-hover hover:text-fg data-popup-open:bg-hover data-popup-open:text-fg aria-pressed:text-fg\",\n  \"data-[state=failed]:text-danger data-[state=failed]:hover:text-danger data-busy:cursor-progress data-busy:text-fg-2 data-busy:hover:text-fg-2\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n  \"data-disabled:cursor-not-allowed data-disabled:text-fg-4 data-disabled:hover:bg-transparent\",\n  // Draws at 28px. On touch it grows to 36px and the hit area reaches 44px.\n  \"pointer-coarse:size-9 before:absolute before:-inset-1 before:content-[''] pointer-fine:before:hidden\",\n  \"motion-reduce:active:scale-100\",\n);\n\nfunction Tip({ label, closeOnClick = true, children }: { label: React.ReactNode; closeOnClick?: boolean; children: React.ReactElement<Record<string, unknown>> }) {\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger render={children} closeOnClick={closeOnClick} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"top\" sideOffset={6} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"origin-(--transform-origin) whitespace-nowrap rounded-md border border-line-2 bg-raised px-1.5 py-[3px] text-[11.5px] leading-4 text-fg shadow-pop\",\n              \"transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:translate-y-0.5 data-starting-style:opacity-0\",\n              \"data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0\",\n            )}\n          >\n            {label}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Generic action\n * -----------------------------------------------------------------------------------------------*/\n\nexport type MessageActionProps = Omit<Toolbar.Button.Props, \"className\" | \"children\"> & {\n  /** The accessible name, and the tooltip unless `tooltip` says otherwise. Say what it does. */\n  label: string;\n  tooltip?: React.ReactNode;\n  /** Keep this action visible while the rest of a hover row is hidden. */\n  keep?: boolean;\n  className?: string;\n  /** The icon. */\n  children: React.ReactNode;\n};\n\n/** Any icon action: read aloud, edit, bookmark. Share is this with a Share icon. */\nexport function MessageAction({ label, tooltip, keep, className, children, ...rest }: MessageActionProps) {\n  return (\n    <Tip label={tooltip ?? label}>\n      <Toolbar.Button aria-label={label} data-keep={keep ? \"\" : undefined} className={cn(buttonClass, className)} {...rest}>\n        {children}\n      </Toolbar.Button>\n    </Tip>\n  );\n}\n\nexport type MessageActionShareProps = Omit<MessageActionProps, \"label\" | \"children\"> & { label?: string };\n\nexport function MessageActionShare({ label = \"Share\", ...rest }: MessageActionShareProps) {\n  return (\n    <MessageAction label={label} {...rest}>\n      <Share />\n    </MessageAction>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Copy\n * -----------------------------------------------------------------------------------------------*/\n\nexport type MessageActionCopyProps = Omit<Toolbar.Button.Props, \"className\" | \"children\" | \"value\" | \"onCopy\"> & {\n  /** The reply's markdown source (not the rendered text), or a function that returns it. */\n  value: string | (() => string | Promise<string>);\n  label?: string;\n  copiedLabel?: string;\n  failedLabel?: string;\n  onCopied?: (text: string) => void;\n  onError?: (error: unknown) => void;\n  className?: string;\n};\n\nexport function MessageActionCopy({\n  value,\n  label = \"Copy\",\n  copiedLabel = \"Copied\",\n  failedLabel = \"Couldn’t copy\",\n  onCopied,\n  onError,\n  className,\n  onClick,\n  ...rest\n}: MessageActionCopyProps) {\n  const { announce } = useContext(ActionsContext);\n  const reduce = !!useReducedMotion();\n  const { state, copy } = useCopy({\n    timeout: 1600,\n    onCopied: (text) => {\n      announce(copiedLabel);\n      onCopied?.(text);\n    },\n    onError: (error) => {\n      announce(`${failedLabel}. Select the text and copy it instead.`);\n      onError?.(error);\n    },\n  });\n  const text = state === \"copied\" ? copiedLabel : state === \"failed\" ? failedLabel : label;\n\n  return (\n    <Tip label={text} closeOnClick={false}>\n      <Toolbar.Button\n        aria-label={label}\n        data-state={state}\n        data-keep={state !== \"idle\" ? \"\" : undefined}\n        onClick={(e) => {\n          onClick?.(e);\n          if (!e.defaultPrevented) copy(value);\n        }}\n        className={cn(buttonClass, className)}\n        {...rest}\n      >\n        <SwapIcon id={state} reduce={reduce}>\n          {state === \"copied\" ? <DrawnCheck reduce={reduce} /> : state === \"failed\" ? <X /> : <Copy />}\n        </SwapIcon>\n      </Toolbar.Button>\n    </Tip>\n  );\n}\n\nfunction SwapIcon({ id, reduce, children }: { id: string; reduce: boolean; children: React.ReactNode }) {\n  return (\n    <span className=\"relative grid size-4 place-items-center\">\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={id}\n          className=\"absolute inset-0 grid place-items-center\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n          animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n          transition={reduce ? { duration: 0.12 } : spring.pop}\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\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=\"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.3, ease: ease.out, delay: 0.04 }}\n      />\n    </svg>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Regenerate\n * -----------------------------------------------------------------------------------------------*/\n\nexport type MessageActionRegenerateProps = Omit<Toolbar.Button.Props, \"className\" | \"children\"> & {\n  /** Return a promise and the arrow keeps turning until it settles. */\n  onRegenerate?: () => void | Promise<unknown>;\n  /** Controlled busy state, for when the new reply streams in elsewhere. */\n  busy?: boolean;\n  label?: string;\n  busyLabel?: string;\n  className?: string;\n};\n\nexport function MessageActionRegenerate({\n  onRegenerate,\n  busy: busyProp,\n  label = \"Regenerate\",\n  busyLabel = \"Regenerating…\",\n  className,\n  onClick,\n  ...rest\n}: MessageActionRegenerateProps) {\n  const { announce } = useContext(ActionsContext);\n  const reduce = !!useReducedMotion();\n  const [pending, setPending] = useState(false);\n  const busy = busyProp ?? pending;\n  const rotate = useMotionValue(0);\n  const turn = useRef<AnimationPlaybackControls | null>(null);\n\n  // While busy the arrow turns steadily. When the work ends it doesn't snap back: it eases\n  // forward to the next full turn, so it always comes to rest pointing the way it started.\n  useEffect(() => {\n    if (reduce) return;\n    turn.current?.stop();\n    const from = rotate.get();\n    if (busy) {\n      turn.current = animate(rotate, [from, from + 360], { duration: 0.9, ease: \"linear\", repeat: Infinity });\n    } else if (from % 360 !== 0) {\n      const rest = Math.ceil(from / 360) * 360 + (from % 360 > 270 ? 360 : 0);\n      turn.current = animate(rotate, rest, { duration: 0.5, ease: ease.out });\n    }\n    return () => turn.current?.stop();\n  }, [busy, reduce, rotate]);\n\n  return (\n    <Tip label={busy ? busyLabel : label} closeOnClick={false}>\n      <Toolbar.Button\n        aria-label={busy ? busyLabel : label}\n        aria-disabled={busy || undefined}\n        data-busy={busy ? \"\" : undefined}\n        data-keep={busy ? \"\" : undefined}\n        onClick={(e) => {\n          onClick?.(e);\n          if (e.defaultPrevented || busy) return;\n          if (!reduce) {\n            // Starts turning on the press itself; a busy state picks the turn up from here.\n            turn.current?.stop();\n            turn.current = animate(rotate, rotate.get() + 360, { duration: 0.6, ease: ease.inOut });\n          }\n          announce(busyLabel);\n          const result = onRegenerate?.();\n          if (result && typeof (result as Promise<unknown>).then === \"function\") {\n            setPending(true);\n            (result as Promise<unknown>).finally(() => setPending(false));\n          }\n        }}\n        className={cn(buttonClass, className)}\n        {...rest}\n      >\n        <motion.span style={{ rotate }} className=\"grid size-4 place-items-center\">\n          <Refresh />\n        </motion.span>\n      </Toolbar.Button>\n    </Tip>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Feedback: thumbs up and down, with an optional \"what went wrong\" follow-up\n * -----------------------------------------------------------------------------------------------*/\n\nexport type MessageFeedback = \"up\" | \"down\" | null;\n\nexport type MessageFeedbackDetails = { reasons: string[]; comment: string };\n\nexport type MessageActionFeedbackProps = {\n  value?: MessageFeedback;\n  defaultValue?: MessageFeedback;\n  onValueChange?: (value: MessageFeedback) => void;\n  /** Offer these after a thumbs down. Leave empty to skip the follow-up. */\n  reasons?: string[];\n  /** Called when the follow-up is sent. */\n  onDetailsSubmit?: (details: MessageFeedbackDetails) => void;\n  upLabel?: string;\n  downLabel?: string;\n  disabled?: boolean;\n};\n\nconst UP = \"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 = \"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\nexport function MessageActionFeedback({\n  value,\n  defaultValue = null,\n  onValueChange,\n  reasons = [],\n  onDetailsSubmit,\n  upLabel = \"Good response\",\n  downLabel = \"Bad response\",\n  disabled,\n}: MessageActionFeedbackProps) {\n  const { announce } = useContext(ActionsContext);\n  const [current, setCurrent] = useControllableState<MessageFeedback>({ value, defaultValue, onChange: onValueChange });\n  const [open, setOpen] = useState(false);\n  const downRef = useRef<HTMLButtonElement>(null);\n\n  const choose = (next: \"up\" | \"down\") => {\n    const resolved = current === next ? null : next;\n    setCurrent(resolved);\n    announce(resolved === \"up\" ? `Marked as ${upLabel.toLowerCase()}` : resolved === \"down\" ? `Marked as ${downLabel.toLowerCase()}` : \"Feedback removed\");\n    setOpen(resolved === \"down\" && reasons.length > 0);\n  };\n\n  return (\n    <>\n      <Thumb dir=\"up\" label={upLabel} pressed={current === \"up\"} disabled={disabled} onPress={() => choose(\"up\")} />\n      <Thumb ref={downRef} dir=\"down\" label={downLabel} pressed={current === \"down\"} disabled={disabled} onPress={() => choose(\"down\")} popupOpen={open} />\n      {reasons.length > 0 && (\n        <FeedbackDetails\n          open={open}\n          onOpenChange={setOpen}\n          anchor={downRef}\n          reasons={reasons}\n          onSubmit={(details) => {\n            setOpen(false);\n            announce(\"Feedback sent\");\n            onDetailsSubmit?.(details);\n          }}\n        />\n      )}\n    </>\n  );\n}\n\nfunction Thumb({\n  dir,\n  label,\n  pressed,\n  disabled,\n  popupOpen,\n  onPress,\n  ref,\n}: {\n  dir: \"up\" | \"down\";\n  label: string;\n  pressed: boolean;\n  disabled?: boolean;\n  popupOpen?: boolean;\n  onPress: () => void;\n  ref?: React.Ref<HTMLButtonElement>;\n}) {\n  const reduce = !!useReducedMotion();\n  const [scope, run] = useAnimate<HTMLSpanElement>();\n\n  return (\n    <Tip label={label}>\n      <Toolbar.Button\n        ref={ref}\n        aria-label={label}\n        aria-pressed={pressed}\n        disabled={disabled}\n        data-keep={pressed ? \"\" : undefined}\n        data-popup-open={popupOpen ? \"\" : undefined}\n        onClick={() => {\n          // The acknowledgement: a small nod in the thumb's own direction as it fills.\n          if (!pressed && !reduce)\n            run(scope.current, { rotate: [0, dir === \"up\" ? -14 : 14, 0], scale: [1, 0.86, 1], y: [0, dir === \"up\" ? -1.5 : 1.5, 0] }, { duration: 0.42, ease: ease.out });\n          onPress();\n        }}\n        className={buttonClass}\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.06 : 0 }}\n            />\n          </svg>\n        </span>\n      </Toolbar.Button>\n    </Tip>\n  );\n}\n\nfunction FeedbackDetails({\n  open,\n  onOpenChange,\n  anchor,\n  reasons,\n  onSubmit,\n}: {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  anchor: React.RefObject<HTMLButtonElement | null>;\n  reasons: string[];\n  onSubmit: (details: MessageFeedbackDetails) => void;\n}) {\n  const [picked, setPicked] = useState<string[]>([]);\n  const [comment, setComment] = useState(\"\");\n  const id = useId();\n  const empty = picked.length === 0 && comment.trim() === \"\";\n\n  return (\n    <Popover.Root\n      open={open}\n      onOpenChange={(next) => onOpenChange(next)}\n      onOpenChangeComplete={(next) => {\n        if (!next) {\n          setPicked([]);\n          setComment(\"\");\n        }\n      }}\n    >\n      <Popover.Portal>\n        <Popover.Positioner anchor={anchor} side=\"bottom\" align=\"start\" sideOffset={8} collisionPadding={12} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            finalFocus={anchor}\n            className={cn(\n              \"w-[min(20rem,calc(100vw-24px))] origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-3 text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:-translate-y-1 data-starting-style:opacity-0\",\n              \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120\",\n              \"data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0\",\n            )}\n          >\n            <form\n              onSubmit={(e) => {\n                e.preventDefault();\n                if (!empty) onSubmit({ reasons: picked, comment: comment.trim() });\n              }}\n            >\n              <div className=\"flex items-start justify-between gap-3\">\n                <Popover.Title className=\"text-[13px] font-medium tracking-[-0.01em]\">What went wrong?</Popover.Title>\n                <Popover.Close\n                  aria-label=\"Skip feedback\"\n                  className={cn(\n                    \"relative -mr-1 -mt-1 grid size-6 shrink-0 place-items-center rounded-md text-fg-3 outline-none transition-[background-color,color,scale] duration-150\",\n                    \"hover:bg-hover hover:text-fg active:scale-[0.9] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                  )}\n                >\n                  <X size={14} />\n                </Popover.Close>\n              </div>\n              <Popover.Description className=\"mt-0.5 text-[12px] text-fg-3\">Pick any that apply. It helps the next answer.</Popover.Description>\n\n              <div role=\"group\" aria-label=\"Reasons\" className=\"mt-3 flex flex-wrap gap-1.5\">\n                {reasons.map((r) => {\n                  const on = picked.includes(r);\n                  return (\n                    <button\n                      key={r}\n                      type=\"button\"\n                      aria-pressed={on}\n                      onClick={() => setPicked((p) => (on ? p.filter((x) => x !== r) : [...p, r]))}\n                      className={cn(\n                        \"inline-flex h-7 select-none items-center gap-1 rounded-full border px-2.5 text-[12px] outline-none\",\n                        \"transition-[background-color,border-color,color,scale,padding] duration-150 ease-out active:scale-[0.96] active:duration-75\",\n                        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                        on ? \"border-fg bg-fg pl-1.5 text-frame\" : \"border-line-2 text-fg-2 hover:border-fg-4 hover:bg-hover hover:text-fg\",\n                      )}\n                    >\n                      <AnimatePresence initial={false}>\n                        {on && (\n                          <motion.span\n                            key=\"check\"\n                            initial={{ width: 0, opacity: 0 }}\n                            animate={{ width: 14, opacity: 1 }}\n                            exit={{ width: 0, opacity: 0, transition: { duration: 0.12 } }}\n                            transition={{ duration: 0.18, ease: ease.out }}\n                            className=\"grid shrink-0 place-items-center overflow-hidden\"\n                          >\n                            <Check size={14} />\n                          </motion.span>\n                        )}\n                      </AnimatePresence>\n                      {r}\n                    </button>\n                  );\n                })}\n              </div>\n\n              <label htmlFor={`${id}-comment`} className=\"sr-only\">\n                Details\n              </label>\n              <textarea\n                id={`${id}-comment`}\n                value={comment}\n                onChange={(e) => setComment(e.target.value)}\n                rows={2}\n                maxLength={500}\n                placeholder=\"What should the answer have said? (optional)\"\n                className={cn(\n                  \"mt-3 block w-full resize-none rounded-lg border border-line-2 bg-frame px-2.5 py-2 text-base leading-5 text-fg outline-none sm:text-[13px]\",\n                  \"placeholder:text-fg-4 transition-[border-color,box-shadow] duration-150 focus:border-fg-4 focus:ring-2 focus:ring-fg/10\",\n                )}\n              />\n\n              <div className=\"mt-3 flex items-center justify-end gap-2\">\n                <Popover.Close\n                  className={cn(\n                    \"h-7 rounded-md px-2.5 text-[12px] font-medium text-fg-2 outline-none transition-[background-color,color,scale] duration-150\",\n                    \"hover:bg-hover hover:text-fg active:scale-[0.97] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  )}\n                >\n                  Skip\n                </Popover.Close>\n                <button\n                  type=\"submit\"\n                  disabled={empty}\n                  className={cn(\n                    \"h-7 rounded-md bg-fg px-2.5 text-[12px] font-medium text-frame outline-none transition-[background-color,opacity,scale] duration-150\",\n                    \"hover:bg-fg/90 active:scale-[0.97] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                    \"disabled:pointer-events-none disabled:opacity-40\",\n                  )}\n                >\n                  Send feedback\n                </button>\n              </div>\n            </form>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * More: the overflow menu\n * -----------------------------------------------------------------------------------------------*/\n\nexport type MessageActionsMoreProps = {\n  label?: string;\n  align?: \"start\" | \"center\" | \"end\";\n  /** `MessageActionsMenuItem`s and `MessageActionsMenuSeparator`s. */\n  children: React.ReactNode;\n};\n\nexport function MessageActionsMore({ label = \"More actions\", align = \"start\", children }: MessageActionsMoreProps) {\n  const [popup, setPopup] = useState<HTMLDivElement | null>(null);\n  const glide = useGlide(popup);\n\n  return (\n    <Menu.Root>\n      <Tip label={label}>\n        <Toolbar.Button render={<Menu.Trigger />} aria-label={label} className={buttonClass}>\n          <MoreH />\n        </Toolbar.Button>\n      </Tip>\n      <Menu.Portal>\n        <Menu.Positioner side=\"bottom\" align={align} sideOffset={6} collisionPadding={8} className=\"z-(--z-dropdown) outline-none\">\n          <Menu.Popup\n            ref={setPopup}\n            className={cn(\n              \"relative isolate min-w-48 max-w-[min(18rem,var(--available-width))] origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:-translate-y-1 data-starting-style:opacity-0\",\n              \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120\",\n              \"data-instant:duration-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0\",\n            )}\n          >\n            <motion.div\n              aria-hidden\n              style={glide}\n              data-glide=\"\"\n              data-tone=\"default\"\n              className=\"pointer-events-none absolute inset-x-1 top-0 -z-10 rounded-lg bg-fg/[0.06] data-[tone=danger]:bg-danger-soft\"\n            />\n            {children}\n          </Menu.Popup>\n        </Menu.Positioner>\n      </Menu.Portal>\n    </Menu.Root>\n  );\n}\n\nexport type MessageActionsMenuItemProps = Omit<Menu.Item.Props, \"className\"> & {\n  icon?: React.ReactNode;\n  variant?: \"default\" | \"danger\";\n  /** A short hint on the right, like a shortcut. */\n  hint?: React.ReactNode;\n  className?: string;\n};\n\nexport function MessageActionsMenuItem({ icon, variant = \"default\", hint, className, children, ...rest }: MessageActionsMenuItemProps) {\n  return (\n    <Menu.Item\n      data-variant={variant}\n      className={cn(\n        \"flex h-8 cursor-default select-none items-center gap-2 rounded-lg px-2 text-[13px] text-fg outline-none\",\n        \"data-disabled:text-fg-4 data-[variant=danger]:text-danger\",\n        className,\n      )}\n      {...rest}\n    >\n      {icon && <span className=\"grid size-4 shrink-0 place-items-center text-fg-3 [[data-variant=danger]_&]:text-danger\">{icon}</span>}\n      <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n      {hint && <span className=\"shrink-0 pl-3 font-mono text-[11px] text-fg-4\">{hint}</span>}\n    </Menu.Item>\n  );\n}\n\nexport function MessageActionsMenuSeparator({ className, ...rest }: Omit<Menu.Separator.Props, \"className\"> & { className?: string }) {\n  return <Menu.Separator className={cn(\"mx-2 my-1 h-px bg-line\", className)} {...rest} />;\n}\n\n// One highlight for the whole menu, moved to the highlighted row. It springs after the pointer\n// and jumps for arrow keys, where motion would read as lag.\nfunction useGlide(popup: HTMLDivElement | null) {\n  const reduce = useReducedMotion();\n  const y = useMotionValue(0);\n  const height = useMotionValue(32);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    if (!popup) return;\n    let keyboard = false;\n    let shown = false;\n    const hl = popup.querySelector<HTMLElement>(\"[data-glide]\");\n    const sync = () => {\n      const row = popup.querySelector<HTMLElement>(\"[data-highlighted]\");\n      if (!row) {\n        if (shown) animate(opacity, 0, { duration: 0.1 });\n        shown = false;\n        return;\n      }\n      if (hl) hl.dataset.tone = row.dataset.variant === \"danger\" ? \"danger\" : \"default\";\n      if (!shown || keyboard || reduce) {\n        y.jump(row.offsetTop);\n        height.jump(row.offsetHeight);\n      } else {\n        animate(y, row.offsetTop, spring.follow);\n        animate(height, row.offsetHeight, spring.follow);\n      }\n      if (!shown) animate(opacity, 1, { duration: keyboard || reduce ? 0 : 0.08 });\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onMove = () => (keyboard = false);\n    const observer = new MutationObserver(sync);\n    observer.observe(popup, { subtree: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    popup.addEventListener(\"keydown\", onKey, true);\n    popup.addEventListener(\"pointermove\", onMove, true);\n    sync();\n    return () => {\n      observer.disconnect();\n      popup.removeEventListener(\"keydown\", onKey, true);\n      popup.removeEventListener(\"pointermove\", onMove, true);\n      opacity.jump(0);\n    };\n  }, [popup, reduce, y, height, opacity]);\n\n  return { y, height, opacity };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/message-actions"
}
