{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feedback-card",
  "title": "Feedback card",
  "description": "A non-blocking card that floats at the bottom right and asks for a 1–5 rating on a morphing face, with an optional comment, then thanks the person and leaves.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/feedback-card/feedback-card.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotionConfig } from \"motion/react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { FaceRating, type RatingLabels } from \"./face-rating\";\n\n/**\n * A non-blocking card that asks how the experience is going.\n *\n * By default it floats at the bottom right of the viewport (a full-width strip\n * on a phone), beside the work rather than over it, and one click dismisses\n * it. The morphing face rating gives a 1–5 score; an optional comment sits\n * beneath it.\n *\n * After a send, the card holds a thank-you for `autoCloseMs` (or until the X),\n * then leaves. After a dismissal it leaves at once; whether and when to ask\n * again is the caller's decision, through `open`.\n *\n * Motion: the entrance is a blur-up rise, the exit reverses it.\n */\n\nexport type FeedbackSubmission = {\n  /** 1–5. */\n  rating: number;\n  /** The mood name for the rating, e.g. \"Good\". */\n  label: string;\n  /** Trimmed; undefined when left empty. */\n  comment?: string;\n};\n\n/**\n * Resolve with nothing (or `true`, or `{ ok: true }`) for success. Reject, or\n * resolve `false` or `{ ok: false }`, to keep the form up with an error line.\n */\nexport type FeedbackSubmitResult = void | boolean | { ok: boolean };\n\nexport type FeedbackCardProps = {\n  /** Controlled visibility. Omit and use `defaultOpen` to let the card manage itself. */\n  open?: boolean;\n  /** Starting visibility when uncontrolled. Defaults to true. */\n  defaultOpen?: boolean;\n  /** Fires whenever the card closes (dismissed, closed after a send, or auto-closed). */\n  onOpenChange?: (open: boolean) => void;\n  /** Sends the rating. Without it the card goes straight to its thank-you. */\n  onSubmit?: (feedback: FeedbackSubmission) => Promise<FeedbackSubmitResult> | FeedbackSubmitResult;\n  /**\n   * The person closed the card without sending (\"Not now\" or the X). Use it to\n   * snooze the prompt. Awaited before the card leaves; not called after a send.\n   */\n  onDismiss?: () => Promise<void> | void;\n  /** \"fixed\" floats bottom-right of the viewport; \"inline\" sits in the flow. Defaults to fixed. */\n  position?: \"fixed\" | \"inline\";\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n  /** Mood names for ratings 1–5. */\n  labels?: RatingLabels;\n  placeholder?: string;\n  /** Longest comment accepted. Defaults to 280. */\n  maxLength?: number;\n  submitLabel?: React.ReactNode;\n  dismissLabel?: React.ReactNode;\n  thankYouTitle?: React.ReactNode;\n  thankYouMessage?: React.ReactNode;\n  /** Shown when `onSubmit` fails. */\n  errorMessage?: React.ReactNode;\n  /** How long the thank-you stays before the card leaves, in ms. 0 keeps it up. Defaults to 5000. */\n  autoCloseMs?: number;\n  className?: string;\n};\n\nconst EASE = [0.2, 0.7, 0.2, 1] as const;\n\nexport function FeedbackCard({\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  position = \"fixed\",\n  className,\n  ...bodyProps\n}: FeedbackCardProps) {\n  const reduceMotion = useReducedMotionConfig();\n  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n  const open = openProp ?? internalOpen;\n\n  const close = () => {\n    if (openProp === undefined) setInternalOpen(false);\n    onOpenChange?.(false);\n  };\n\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          initial={\n            reduceMotion ? { opacity: 0 } : { opacity: 0, y: 20, filter: \"blur(8px)\", scale: 0.97 }\n          }\n          animate={reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\", scale: 1 }}\n          exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, filter: \"blur(6px)\", scale: 0.97 }}\n          transition={{ duration: 0.5, ease: EASE }}\n          className={cn(\n            position === \"fixed\"\n              ? \"fixed right-6 bottom-[calc(1.5rem+env(safe-area-inset-bottom))] z-30 w-[340px] max-w-[calc(100vw-3rem)] max-sm:inset-x-3 max-sm:bottom-[calc(0.75rem+env(safe-area-inset-bottom))] max-sm:w-auto max-sm:max-w-none sm:w-[360px]\"\n              : \"relative w-full max-w-[360px]\",\n            className,\n          )}\n        >\n          {/* Remounts on every opening, so a reopened card starts blank. */}\n          <FeedbackCardBody {...bodyProps} onClose={close} />\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\ntype BodyProps = Omit<\n  FeedbackCardProps,\n  \"open\" | \"defaultOpen\" | \"onOpenChange\" | \"position\" | \"className\"\n> & { onClose: () => void };\n\nconst DEFAULT_LABELS: RatingLabels = [\"Terrible\", \"Bad\", \"Okay\", \"Good\", \"Amazing\"];\n\nfunction isFailure(result: FeedbackSubmitResult) {\n  return result === false || (typeof result === \"object\" && result !== null && !result.ok);\n}\n\nfunction FeedbackCardBody({\n  onSubmit,\n  onDismiss,\n  onClose,\n  title = \"How’s your experience?\",\n  description = \"Rate your experience so far.\",\n  labels = DEFAULT_LABELS,\n  placeholder = \"Anything else? (optional)\",\n  maxLength = 280,\n  submitLabel = \"Send feedback\",\n  dismissLabel = \"Not now\",\n  thankYouTitle = \"Thanks for your feedback!\",\n  thankYouMessage = \"We’ll use it to make things better.\",\n  errorMessage = \"That could not be sent. Try again.\",\n  autoCloseMs = 5000,\n}: BodyProps) {\n  const [rating, setRating] = React.useState(0);\n  const [comment, setComment] = React.useState(\"\");\n  const [submitted, setSubmitted] = React.useState(false);\n  const [failed, setFailed] = React.useState(false);\n  const [isPending, startTransition] = React.useTransition();\n  const dismissTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  React.useEffect(\n    () => () => {\n      if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);\n    },\n    [],\n  );\n\n  const handleSubmit = () => {\n    if (rating === 0) return;\n    setFailed(false);\n    startTransition(async () => {\n      let result: FeedbackSubmitResult;\n      try {\n        result = await onSubmit?.({\n          rating,\n          label: labels[rating - 1],\n          comment: comment.trim() || undefined,\n        });\n      } catch {\n        setFailed(true);\n        return;\n      }\n      if (isFailure(result)) {\n        setFailed(true);\n        return;\n      }\n      setSubmitted(true);\n      // Hold the thank-you, then animate out.\n      if (autoCloseMs > 0) dismissTimerRef.current = setTimeout(onClose, autoCloseMs);\n    });\n  };\n\n  const handleDismiss = () => {\n    if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);\n    startTransition(async () => {\n      if (!submitted) {\n        try {\n          await onDismiss?.();\n        } catch {\n          // A failed snooze must never trap the card on screen.\n        }\n      }\n      onClose();\n    });\n  };\n\n  return (\n    <div className=\"relative rounded-2xl border border-rule bg-popover px-6 py-5 font-body text-popover-foreground shadow-lg\">\n      <button\n        type=\"button\"\n        onClick={handleDismiss}\n        aria-label=\"Close\"\n        className={cn(\n          \"absolute top-3.5 right-3.5 flex h-7 w-7 items-center justify-center rounded-full text-faint transition-colors\",\n          \"hover:bg-paper hover:text-ink/80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n          // A 44px hit area on a touch screen; the button itself stays small.\n          \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\",\n        )}\n      >\n        <X className=\"h-4 w-4\" />\n      </button>\n\n      {submitted ? (\n        <div className=\"flex flex-col items-center gap-2 py-3 pr-2\" role=\"status\">\n          <span className=\"text-2xl\" role=\"img\" aria-label=\"Thank you\">\n            🙏\n          </span>\n          <p className=\"text-sm font-bold text-ink\">{thankYouTitle}</p>\n          <p className=\"text-center text-xs text-dim\">{thankYouMessage}</p>\n        </div>\n      ) : (\n        <>\n          <h3 className=\"mt-1 pr-6 text-base font-extrabold tracking-tight text-ink\">{title}</h3>\n          <p className=\"mt-1 text-xs leading-[1.6] text-dim\">{description}</p>\n\n          <div className=\"mt-4 flex justify-center\">\n            <FaceRating\n              value={rating}\n              onValueChange={setRating}\n              labels={labels}\n              size=\"md\"\n              label=\"How was your experience?\"\n            />\n          </div>\n\n          <div className=\"mt-4\">\n            <textarea\n              value={comment}\n              onChange={(e) => setComment(e.target.value)}\n              placeholder={placeholder}\n              aria-label=\"Comment\"\n              maxLength={maxLength}\n              rows={2}\n              className=\"w-full resize-none rounded-xl border border-rule bg-paper px-3 py-2 text-sm leading-[1.6] text-ink placeholder:text-faint focus:border-faint focus:ring-0 focus:outline-none\"\n            />\n            {comment.length > 0 && (\n              <p className=\"mt-0.5 text-right text-[10px] text-faint tabular-nums\">\n                {comment.length}/{maxLength}\n              </p>\n            )}\n          </div>\n\n          {failed && (\n            <p className=\"mt-2 text-xs font-semibold text-stop\" role=\"alert\">\n              {errorMessage}\n            </p>\n          )}\n\n          <div className=\"mt-4 flex items-center justify-between gap-3\">\n            <button\n              type=\"button\"\n              onClick={handleDismiss}\n              disabled={isPending}\n              className=\"rounded-full px-3 py-1.5 text-xs font-medium text-faint transition-colors hover:bg-paper hover:text-dim disabled:opacity-50\"\n            >\n              {dismissLabel}\n            </button>\n            <OriginFillButton\n              onClick={handleSubmit}\n              disabled={rating === 0 || isPending}\n              loading={isPending}\n            >\n              {submitLabel}\n            </OriginFillButton>\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n\n// ─── Origin-fill button ─────────────────────────────────────────────────────\n// A trimmed copy of 180Connect's OriginButton (default variant, size sm): a\n// glassy ink pill whose lime fill grows from the point the pointer came in.\n\nfunction coverDiameter(width: number, height: number, x: number, y: number) {\n  return Math.ceil(\n    2 *\n      Math.max(\n        Math.hypot(x, y),\n        Math.hypot(width - x, y),\n        Math.hypot(x, height - y),\n        Math.hypot(width - x, height - y),\n      ),\n  );\n}\n\nfunction OriginFillButton({\n  children,\n  onClick,\n  disabled = false,\n  loading = false,\n}: {\n  children: React.ReactNode;\n  onClick: () => void;\n  disabled?: boolean;\n  loading?: boolean;\n}) {\n  const reduceMotion = useReducedMotionConfig();\n  const isDisabled = disabled || loading;\n  const [hovered, setHovered] = React.useState(false);\n  const [origin, setOrigin] = React.useState({ x: 0, y: 0, size: 0 });\n  const showFill = !isDisabled && hovered;\n\n  const fromPoint = (node: HTMLElement, x: number, y: number) => {\n    const rect = node.getBoundingClientRect();\n    setOrigin({ x, y, size: coverDiameter(rect.width, rect.height, x, y) * 1.25 + 24 });\n  };\n  const fromPointer = (event: React.PointerEvent<HTMLButtonElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    fromPoint(event.currentTarget, event.clientX - rect.left, event.clientY - rect.top);\n  };\n\n  return (\n    <motion.button\n      type=\"button\"\n      onClick={onClick}\n      disabled={isDisabled}\n      aria-busy={loading || undefined}\n      onPointerEnter={(event) => {\n        if (isDisabled) return;\n        fromPointer(event);\n        setHovered(true);\n      }}\n      onPointerDown={(event) => {\n        if (isDisabled || event.button !== 0) return;\n        fromPointer(event);\n        setHovered(true);\n      }}\n      onPointerLeave={() => setHovered(false)}\n      onFocus={(event) => {\n        if (isDisabled || !event.currentTarget.matches(\":focus-visible\")) return;\n        const rect = event.currentTarget.getBoundingClientRect();\n        fromPoint(event.currentTarget, rect.width / 2, rect.height / 2);\n        setHovered(true);\n      }}\n      onBlur={() => setHovered(false)}\n      whileTap={isDisabled || reduceMotion ? undefined : { scale: 0.98 }}\n      className={cn(\n        \"group relative inline-flex h-8.5 cursor-pointer touch-manipulation items-center justify-center gap-1.5 overflow-hidden rounded-full bg-transparent px-4 text-xs font-semibold tracking-[-0.02em] whitespace-nowrap select-none pointer-coarse:min-h-11\",\n        \"transition-[color] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]\",\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover focus-visible:outline-none\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        showFill ? \"text-on-lime\" : \"text-on-solid\",\n      )}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute inset-0 rounded-full bg-ink/85 shadow-xs ring-1 ring-on-solid/25 backdrop-blur-md transition-opacity duration-300\",\n          \"shadow-[inset_0_1px_0_color-mix(in_oklab,var(--on-solid)_30%,transparent)]\",\n          showFill ? \"opacity-0\" : \"opacity-100\",\n        )}\n      />\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-lime\"\n        initial={false}\n        animate={{ scale: showFill && origin.size > 0 ? 1 : 0 }}\n        transition={reduceMotion ? { duration: 0 } : { duration: 0.45, ease: [0.16, 1, 0.3, 1] }}\n        style={{ width: origin.size, height: origin.size, left: origin.x, top: origin.y }}\n      />\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute inset-0 rounded-full shadow-xs ring-1 ring-rule transition-opacity duration-300\",\n          showFill ? \"opacity-100\" : \"opacity-0\",\n        )}\n      />\n      <span className=\"relative z-10 inline-flex items-center justify-center gap-2\">\n        {loading && (\n          <span className=\"inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent\" />\n        )}\n        {children}\n      </span>\n    </motion.button>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/items/feedback-card/face-rating.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotionConfig } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A 1–5 rating whose face morphs with the mood: the mouth bends from a deep\n * frown to a wide smile, the eyes squint or widen, and brows appear when it is\n * upset. Hovering a segment previews that face; a commit makes it bounce.\n *\n * The colours run along the theme's ramp (stop → ramp-orange →\n * milestone-yellow → go-pale → go), so the face is a mark, never text. They\n * change through a CSS colour transition on `currentColor` rather than a motion\n * tween, so any colour space a theme writes its tokens in still animates.\n */\n\nexport const RATING_LEVELS = [1, 2, 3, 4, 5] as const;\n\n/** One ramp token per level, bad to good. */\nexport const RATING_COLORS = [\n  \"var(--stop)\",\n  \"var(--ramp-orange)\",\n  \"var(--milestone-yellow)\",\n  \"var(--go-pale)\",\n  \"var(--go)\",\n] as const;\n\n/** The face while nothing is rated. */\nexport const RATING_UNSET_COLOR = \"var(--faint)\";\n\n/** Mouth control-point Y per level: deep frown → flat → big smile. */\nconst MOUTH_CONTROL_Y = [34, 41, 47, 54, 61] as const;\n/** Mouth corner Y per level: corners droop when upset, lift when delighted. */\nconst MOUTH_CORNER_Y = [49.5, 48, 47, 46, 44.5] as const;\n/** Eye squash per level: narrowed when upset, wide open when amazed. */\nconst EYE_SCALE_Y = [0.45, 0.6, 0.9, 1, 1.25] as const;\n\nfunction mouthPath(level: number): string {\n  const clamped = Math.max(1, Math.min(5, Math.round(level || 3)));\n  const corner = MOUTH_CORNER_Y[clamped - 1];\n  const control = MOUTH_CONTROL_Y[clamped - 1];\n  return `M 22 ${corner} Q 36 ${control} 50 ${corner}`;\n}\n\nexport type RatingLabels = [string, string, string, string, string];\n\nexport interface FaceRatingProps {\n  /** Controlled rating from 1–5; 0 means unset. Leave undefined for uncontrolled use. */\n  value?: number;\n  /** Initial rating when uncontrolled. Default 0 (unset). */\n  defaultValue?: number;\n  /** Fires with the next rating on every commit. */\n  onValueChange?: (value: number) => void;\n  /** Mood names for levels 1–5, shown under the widget and read to screen readers. */\n  labels?: RatingLabels;\n  /** Show the mood label under the segments. Default true. */\n  showLabel?: boolean;\n  /** Visual size of the widget. Default \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Accessible name of the radio group. */\n  label?: string;\n  className?: string;\n}\n\nconst DEFAULT_LABELS: RatingLabels = [\"Terrible\", \"Bad\", \"Okay\", \"Good\", \"Amazing\"];\n\n/** Softer, organic spring shared by the mouth morph and the eyes. */\nconst MORPH_SPRING = { type: \"spring\", stiffness: 260, damping: 22 } as const;\n/** Snappy spring for segment press feedback and the checked-ring pop. */\nconst SPRING_SNAPPY = { type: \"spring\", stiffness: 500, damping: 30 } as const;\n/** Eyes and eyebrows begin this long after the mouth, in seconds. */\nconst FEATURE_STAGGER = 0.03;\n/** Ease for the mood label crossfade. */\nconst LABEL_TRANSITION = { duration: 0.18, ease: [0.22, 1, 0.36, 1] } as const;\n/** Colour change, close to the morph spring's settle time. */\nconst COLOR_TRANSITION = \"color 400ms cubic-bezier(0.22, 1, 0.36, 1)\";\n\nconst SIZES = {\n  sm: {\n    face: 56,\n    root: \"gap-2\",\n    row: \"gap-1.5\",\n    segment: \"h-6 w-6 text-[10px]\",\n    labelBox: \"h-4\",\n    labelText: \"text-xs\",\n  },\n  md: {\n    face: 72,\n    root: \"gap-3\",\n    row: \"gap-2\",\n    segment: \"h-7 w-7 text-xs\",\n    labelBox: \"h-5\",\n    labelText: \"text-sm\",\n  },\n  lg: {\n    face: 96,\n    root: \"gap-4\",\n    row: \"gap-2.5\",\n    segment: \"h-9 w-9 text-sm\",\n    labelBox: \"h-6\",\n    labelText: \"text-base\",\n  },\n} as const;\n\nexport function FaceRating({\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  labels = DEFAULT_LABELS,\n  showLabel = true,\n  size = \"md\",\n  label = \"How was your experience?\",\n  className,\n}: FaceRatingProps) {\n  const reduceMotion = useReducedMotionConfig();\n  const [internalValue, setInternalValue] = React.useState(defaultValue);\n  const [hovered, setHovered] = React.useState(0);\n  const [bouncing, setBouncing] = React.useState(false);\n  const segmentRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  const value = valueProp ?? internalValue;\n  // The face previews the hovered level and falls back to the committed value.\n  const active = hovered || value;\n  const level = active || 3;\n  const faceColor = active ? RATING_COLORS[active - 1] : RATING_UNSET_COLOR;\n  const sizes = SIZES[size];\n\n  const spring = reduceMotion ? { duration: 0 } : MORPH_SPRING;\n  // Eyes and eyebrows trail the mouth slightly so the face reads organically.\n  const featureTransition = reduceMotion\n    ? { duration: 0 }\n    : { ...MORPH_SPRING, delay: FEATURE_STAGGER };\n\n  const commit = (next: number) => {\n    if (valueProp === undefined) setInternalValue(next);\n    onValueChange?.(next);\n    if (!reduceMotion) setBouncing(true);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    let next: number;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowUp\") {\n      next = Math.min(5, value + 1);\n    } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowDown\") {\n      next = value === 0 ? 1 : Math.max(1, value - 1);\n    } else {\n      return;\n    }\n    event.preventDefault();\n    segmentRefs.current[next - 1]?.focus();\n    if (next !== value) commit(next);\n  };\n\n  return (\n    <div className={cn(\"flex flex-col items-center\", sizes.root, className)}>\n      {/* Morphing face: decorative; the radio group below carries the state. */}\n      <motion.div\n        aria-hidden=\"true\"\n        initial={false}\n        animate={bouncing ? { y: [0, -6, 0], scale: [1, 1.08, 1] } : { y: 0, scale: 1 }}\n        transition={\n          bouncing ? { duration: 0.45, times: [0, 0.4, 1], ease: \"easeOut\" } : { duration: 0 }\n        }\n        onAnimationComplete={() => setBouncing(false)}\n      >\n        <svg\n          viewBox=\"0 0 72 72\"\n          width={sizes.face}\n          height={sizes.face}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          style={{ color: faceColor, transition: reduceMotion ? undefined : COLOR_TRANSITION }}\n        >\n          <circle cx={36} cy={36} r={30} strokeWidth={4} />\n          {/* Eyebrows: only surface while upset (levels 1–2). */}\n          <motion.line\n            x1={19}\n            y1={19}\n            x2={30}\n            y2={23}\n            strokeWidth={3}\n            initial={false}\n            animate={{ opacity: level <= 2 ? 1 : 0 }}\n            transition={featureTransition}\n          />\n          <motion.line\n            x1={42}\n            y1={23}\n            x2={53}\n            y2={19}\n            strokeWidth={3}\n            initial={false}\n            animate={{ opacity: level <= 2 ? 1 : 0 }}\n            transition={featureTransition}\n          />\n          <motion.ellipse\n            cx={25}\n            cy={30}\n            rx={3.5}\n            ry={4}\n            fill=\"currentColor\"\n            stroke=\"none\"\n            style={{ originX: 0.5, originY: 0.5 }}\n            initial={false}\n            animate={{ scaleY: EYE_SCALE_Y[level - 1] }}\n            transition={featureTransition}\n          />\n          <motion.ellipse\n            cx={47}\n            cy={30}\n            rx={3.5}\n            ry={4}\n            fill=\"currentColor\"\n            stroke=\"none\"\n            style={{ originX: 0.5, originY: 0.5 }}\n            initial={false}\n            animate={{ scaleY: EYE_SCALE_Y[level - 1] }}\n            transition={featureTransition}\n          />\n          {/* Mouth: the core morph between frown, flat and smile. */}\n          <motion.path\n            strokeWidth={4}\n            initial={false}\n            animate={{ d: mouthPath(level) }}\n            transition={spring}\n          />\n        </svg>\n      </motion.div>\n\n      <div\n        role=\"radiogroup\"\n        aria-label={label}\n        className={cn(\n          \"flex items-center rounded-full border border-rule-soft bg-paper p-1 shadow-inner backdrop-blur-md\",\n          sizes.row,\n        )}\n        onMouseLeave={() => setHovered(0)}\n      >\n        {RATING_LEVELS.map((segmentLevel) => {\n          const isActive = value === segmentLevel;\n          const color = RATING_COLORS[segmentLevel - 1];\n          return (\n            <motion.button\n              key={segmentLevel}\n              ref={(node) => {\n                segmentRefs.current[segmentLevel - 1] = node;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={isActive}\n              aria-label={`Rate ${segmentLevel} of 5: ${labels[segmentLevel - 1]}`}\n              tabIndex={isActive || (value === 0 && segmentLevel === 1) ? 0 : -1}\n              onClick={() => commit(segmentLevel)}\n              onKeyDown={handleKeyDown}\n              onMouseEnter={() => setHovered(segmentLevel)}\n              whileTap={reduceMotion ? undefined : { scale: 0.9 }}\n              transition={reduceMotion ? { duration: 0 } : SPRING_SNAPPY}\n              className={cn(\n                \"relative inline-flex touch-manipulation select-none items-center justify-center rounded-full font-body font-bold tabular-nums transition-all duration-200\",\n                \"focus:outline-none focus-visible:ring-2 focus-visible:ring-rule\",\n                sizes.segment,\n                isActive ? \"bg-card shadow-xs\" : \"text-dim hover:bg-paper-sunk hover:text-ink\",\n              )}\n              style={isActive ? { color } : undefined}\n            >\n              <AnimatePresence initial={false}>\n                {isActive && (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none absolute inset-0 rounded-full\"\n                    style={{\n                      backgroundColor: `color-mix(in oklab, ${color} 9%, transparent)`,\n                      boxShadow: `inset 0 0 0 1.5px ${color}`,\n                    }}\n                    initial={reduceMotion ? false : { scale: 0.5, opacity: 0 }}\n                    animate={{ scale: 1, opacity: 1 }}\n                    exit={\n                      reduceMotion\n                        ? { opacity: 0, transition: { duration: 0 } }\n                        : { scale: 0.5, opacity: 0 }\n                    }\n                    transition={reduceMotion ? { duration: 0 } : SPRING_SNAPPY}\n                  />\n                )}\n              </AnimatePresence>\n              <span className=\"relative z-10\">{segmentLevel}</span>\n            </motion.button>\n          );\n        })}\n      </div>\n\n      {showLabel && (\n        <div\n          className={cn(\"relative flex items-center justify-center overflow-hidden\", sizes.labelBox)}\n        >\n          {/* popLayout crossfades the labels so hover → leave never flashes\n              an empty frame while settling back to the committed value. */}\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {active > 0 && (\n              <motion.span\n                key={active}\n                className={cn(\"font-body font-medium text-dim\", sizes.labelText)}\n                initial={{ opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: -4 }}\n                transition={reduceMotion ? { duration: 0 } : LABEL_TRANSITION}\n              >\n                {labels[active - 1]}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use to ask for an occasional in-product rating without interrupting work; the caller decides when it shows. Do not use for a required form, a bug report with fields, or anything that must block the page; use a dialog for those.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/feedback-prompt.tsx"
    },
    "credits": [
      {
        "name": "Spectrum UI (Face Rating)",
        "url": "https://ui.spectrumhq.in",
        "license": "Apache-2.0"
      }
    ]
  },
  "type": "registry:component"
}
