{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"approval-card","type":"registry:component","title":"Approval Card","description":"A human-in-the-loop decision surface for approvals, single or multiple-choice questions, custom responses, and multi-step review flows.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/approval-card/index.tsx","type":"registry:component","target":"@components/agents/approval-card/index.tsx","content":"\"use client\";\n// beui.dev/components/agents/approval-card\n\nimport {\n  ArrowLeft,\n  ArrowRight,\n  Check,\n  CircleHelp,\n  LoaderCircle,\n  MessageSquareText,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { AgentDisclosure } from \"@/components/agents/agent-disclosure\";\nimport { ActionSwapRollText } from \"@/components/motion/action-swap-roll\";\nimport { Button } from \"@/components/motion/button\";\nimport { Checkbox } from \"@/components/motion/checkbox\";\nimport { Input } from \"@/components/motion/input\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/motion/radio\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  ApprovalCardAnswer,\n  ApprovalCardAnswers,\n  ApprovalCardProps,\n  ApprovalCardQuestion,\n  ApprovalCardStatus,\n} from \"./types\";\n\nexport type {\n  ApprovalCardAnswer,\n  ApprovalCardAnswers,\n  ApprovalCardOption,\n  ApprovalCardProps,\n  ApprovalCardQuestion,\n  ApprovalCardStatus,\n} from \"./types\";\n\nconst EMPTY_ANSWER: ApprovalCardAnswer = { selected: [], custom: \"\" };\n\nfunction getStatusLabel(status: ApprovalCardStatus) {\n  if (status === \"submitting\") return \"Submitting\";\n  if (status === \"approved\") return \"Approved\";\n  if (status === \"rejected\") return \"Rejected\";\n  if (status === \"changes-requested\") return \"Changes requested\";\n  if (status === \"answered\") return \"Response submitted\";\n  return \"Input required\";\n}\n\nfunction getStatusClass(status: ApprovalCardStatus) {\n  if (status === \"approved\" || status === \"answered\") {\n    return \"text-emerald-600 dark:text-emerald-400\";\n  }\n  if (status === \"rejected\") return \"text-rose-600 dark:text-rose-400\";\n  if (status === \"changes-requested\") {\n    return \"text-amber-600 dark:text-amber-400\";\n  }\n  return \"text-muted-foreground\";\n}\n\nfunction getStatusBadgeClass(status: ApprovalCardStatus) {\n  if (status === \"pending\" || status === \"changes-requested\") {\n    return \"border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400\";\n  }\n  if (status === \"submitting\") {\n    return \"border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400\";\n  }\n  if (status === \"approved\" || status === \"answered\") {\n    return \"border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400\";\n  }\n  return \"border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400\";\n}\n\nfunction isAnswered(answer: ApprovalCardAnswer) {\n  return answer.selected.length > 0 || Boolean(answer.custom?.trim());\n}\n\nfunction QuestionOptions({\n  question,\n  answer,\n  disabled,\n  onChange,\n  onSingleSelect,\n}: {\n  question: ApprovalCardQuestion;\n  answer: ApprovalCardAnswer;\n  disabled: boolean;\n  onChange: (answer: ApprovalCardAnswer) => void;\n  onSingleSelect?: () => void;\n}) {\n  const custom = answer.custom ?? \"\";\n\n  return (\n    <div className=\"mt-3\">\n      {question.options?.length ? (\n        question.multiple ? (\n          <div className=\"grid gap-0.5\">\n            {question.options.map((option) => (\n              <Checkbox\n                key={option.value}\n                checked={answer.selected.includes(option.value)}\n                disabled={disabled || option.disabled}\n                label={option.label}\n                onCheckedChange={(checked) =>\n                  onChange({\n                    ...answer,\n                    selected: checked\n                      ? [...answer.selected, option.value]\n                      : answer.selected.filter((value) => value !== option.value),\n                  })\n                }\n                className=\"min-h-9 rounded-lg px-1.5 py-1\"\n              />\n            ))}\n          </div>\n        ) : (\n          <RadioGroup\n            value={answer.selected[0] ?? \"\"}\n            onValueChange={(value) => {\n              onChange({ selected: [value], custom: \"\" });\n              onSingleSelect?.();\n            }}\n            className=\"gap-0.5\"\n          >\n            {question.options.map((option) => (\n              <RadioGroupItem\n                key={option.value}\n                value={option.value}\n                label={option.label}\n                disabled={disabled || option.disabled}\n                className=\"min-h-9 rounded-lg px-1.5 py-1\"\n              />\n            ))}\n          </RadioGroup>\n        )\n      ) : null}\n\n      {question.allowCustom ? (\n        <Input\n          value={custom}\n          disabled={disabled}\n          placeholder={question.customPlaceholder ?? \"Add another response…\"}\n          onChange={(value) =>\n            onChange({\n              selected: question.multiple ? answer.selected : [],\n              custom: value,\n            })\n          }\n          className={cn(\"p-0.5\", question.options?.length && \"mt-1.5\")}\n          classNames={{\n            field:\n              \"h-10 rounded-xl border-0 bg-background/70 focus-within:bg-background\",\n            input: \"px-3 text-sm\",\n          }}\n        />\n      ) : null}\n    </div>\n  );\n}\n\nfunction ProgressDots({ current, ids }: { current: number; ids: string[] }) {\n  return (\n    <span className=\"flex gap-1.5\">\n      <span className=\"sr-only\">\n        Question {current + 1} of {ids.length}\n      </span>\n      {ids.map((id, index) => (\n        <motion.span\n          key={id}\n          aria-hidden=\"true\"\n          initial={{\n            scale: index === current ? 1 : 0.75,\n            opacity: index <= current ? 1 : 0.35,\n          }}\n          animate={{\n            scale: index === current ? 1 : 0.75,\n            opacity: index <= current ? 1 : 0.35,\n          }}\n          transition={SPRING_SWAP}\n          className=\"size-1.5 rounded-full bg-foreground\"\n        />\n      ))}\n    </span>\n  );\n}\n\nexport function ApprovalCard({\n  title = \"Approval required\",\n  description,\n  children,\n  questions = [],\n  status = \"pending\",\n  answers,\n  defaultAnswers = {},\n  onAnswersChange,\n  step,\n  defaultStep = 0,\n  onStepChange,\n  onSubmit,\n  onApprove,\n  onReject,\n  onRequestChanges,\n  onDismiss,\n  approveLabel = \"Approve\",\n  submitLabel = \"Submit response\",\n  result,\n  className,\n}: ApprovalCardProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [internalAnswers, setInternalAnswers] =\n    useState<ApprovalCardAnswers>(defaultAnswers);\n  const [internalStep, setInternalStep] = useState(defaultStep);\n  const autoAdvanceTimer = useRef<number | undefined>(undefined);\n  const currentAnswers = answers ?? internalAnswers;\n  const currentStep = Math.min(\n    Math.max(0, step ?? internalStep),\n    Math.max(0, questions.length - 1),\n  );\n  const question = questions[currentStep];\n  const questionMode = questions.length > 0;\n  const pending = status === \"pending\";\n  const busy = status === \"submitting\";\n  const interactive = pending || busy;\n  const currentAnswer = question\n    ? (currentAnswers[question.id] ?? EMPTY_ANSWER)\n    : EMPTY_ANSWER;\n  const displayTitle = question?.title ?? title;\n  const titleKey = question?.id ?? String(status);\n  const statusLabel = getStatusLabel(status);\n\n  const clearAutoAdvance = useCallback(() => {\n    if (autoAdvanceTimer.current === undefined) return;\n    window.clearTimeout(autoAdvanceTimer.current);\n    autoAdvanceTimer.current = undefined;\n  }, []);\n\n  useEffect(() => clearAutoAdvance, [clearAutoAdvance]);\n\n  const setAnswers = useCallback(\n    (next: ApprovalCardAnswers) => {\n      if (answers === undefined) setInternalAnswers(next);\n      onAnswersChange?.(next);\n    },\n    [answers, onAnswersChange],\n  );\n\n  const setStep = (next: number) => {\n    clearAutoAdvance();\n    if (step === undefined) setInternalStep(next);\n    onStepChange?.(next);\n  };\n\n  const updateCurrentAnswer = (next: ApprovalCardAnswer) => {\n    if (!question) return;\n    setAnswers({ ...currentAnswers, [question.id]: next });\n  };\n\n  const continueQuestion = () => {\n    if (currentStep < questions.length - 1) {\n      setStep(currentStep + 1);\n      return;\n    }\n    onSubmit?.(currentAnswers);\n  };\n\n  const queueAutoAdvance = () => {\n    if (\n      !question ||\n      question.multiple ||\n      question.autoAdvance === false ||\n      currentStep >= questions.length - 1 ||\n      busy\n    ) {\n      return;\n    }\n\n    clearAutoAdvance();\n    autoAdvanceTimer.current = window.setTimeout(() => {\n      setStep(currentStep + 1);\n    }, 240);\n  };\n\n  return (\n    <div\n      data-state={status}\n      aria-busy={busy}\n      className={cn(\n        \"w-full overflow-hidden rounded-2xl bg-muted p-4 text-sm\",\n        className,\n      )}\n    >\n      <div className=\"flex items-start gap-3\">\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"grid size-5 shrink-0 place-items-center text-muted-foreground\",\n            getStatusClass(status),\n          )}\n        >\n          {busy ? (\n            <LoaderCircle className={cn(\"size-4\", !reduce && \"animate-spin\")} />\n          ) : interactive ? (\n            questionMode ? (\n              <CircleHelp className=\"size-4\" />\n            ) : (\n              <MessageSquareText className=\"size-4\" />\n            )\n          ) : status === \"rejected\" ? (\n            <X className=\"size-4\" />\n          ) : (\n            <Check className=\"size-4\" />\n          )}\n        </span>\n\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex min-w-0 items-start gap-3\">\n            <h3 className=\"min-w-0 flex-1 text-base font-medium leading-5 text-foreground\">\n              <ActionSwapRollText value={titleKey}>\n                {displayTitle}\n              </ActionSwapRollText>\n            </h3>\n            {questionMode && interactive ? (\n              <span className=\"shrink-0 text-xs tabular-nums text-muted-foreground/65\">\n                {currentStep + 1}/{questions.length}\n              </span>\n            ) : (\n              <span\n                className={cn(\n                  \"shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors\",\n                  getStatusBadgeClass(status),\n                )}\n              >\n                {statusLabel}\n              </span>\n            )}\n            {onDismiss ? (\n              <button\n                type=\"button\"\n                aria-label=\"Dismiss\"\n                onClick={onDismiss}\n                className=\"grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n              >\n                <X className=\"size-4\" />\n              </button>\n            ) : null}\n          </div>\n\n          <AgentDisclosure open={interactive}>\n            {questionMode && question ? (\n              <AnimatePresence initial={false} mode=\"wait\">\n                <motion.div\n                  key={question.id}\n                  initial={reduce ? { opacity: 1 } : { opacity: 0, x: 8 }}\n                  animate={{ opacity: 1, x: 0 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, x: -6 }}\n                  transition={{ duration: reduce ? 0 : 0.2, ease: EASE_OUT }}\n                >\n                  {question.description ? (\n                    <p className=\"mt-1 leading-5 text-muted-foreground\">\n                      {question.description}\n                    </p>\n                  ) : null}\n                  <QuestionOptions\n                    question={question}\n                    answer={currentAnswer}\n                    disabled={busy}\n                    onChange={updateCurrentAnswer}\n                    onSingleSelect={queueAutoAdvance}\n                  />\n                </motion.div>\n              </AnimatePresence>\n            ) : (\n              <div>\n                {description ? (\n                  <p className=\"mt-1 leading-5 text-muted-foreground\">\n                    {description}\n                  </p>\n                ) : null}\n                {children ? <div className=\"mt-3\">{children}</div> : null}\n              </div>\n            )}\n\n            {questionMode ? (\n              <div className=\"mt-4 flex items-center gap-3\">\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  aria-label=\"Previous question\"\n                  disabled={busy || currentStep === 0}\n                  onClick={() => setStep(currentStep - 1)}\n                  className=\"rounded-full\"\n                >\n                  <ArrowLeft className=\"size-4\" />\n                </Button>\n                <ProgressDots\n                  current={currentStep}\n                  ids={questions.map((item) => item.id)}\n                />\n                <Button\n                  size={currentStep === questions.length - 1 ? \"sm\" : \"icon\"}\n                  aria-label={\n                    currentStep === questions.length - 1\n                      ? \"Submit response\"\n                      : \"Next question\"\n                  }\n                  disabled={busy || !isAnswered(currentAnswer)}\n                  onClick={continueQuestion}\n                  className=\"ml-auto rounded-full\"\n                >\n                  {busy ? (\n                    <LoaderCircle className={cn(\"size-4\", !reduce && \"animate-spin\")} />\n                  ) : currentStep === questions.length - 1 ? (\n                    <>\n                      {submitLabel}\n                      <ArrowRight className=\"size-3.5\" />\n                    </>\n                  ) : (\n                    <ArrowRight className=\"size-4\" />\n                  )}\n                </Button>\n              </div>\n            ) : (\n              <div className=\"mt-4 flex flex-wrap items-center gap-2\">\n                <Button\n                  size=\"sm\"\n                  disabled={busy}\n                  onClick={onApprove}\n                  className=\"rounded-full\"\n                >\n                  {approveLabel}\n                </Button>\n                {onRequestChanges ? (\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    disabled={busy}\n                    onClick={onRequestChanges}\n                    className=\"rounded-full\"\n                  >\n                    Request changes\n                  </Button>\n                ) : null}\n                {onReject ? (\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    disabled={busy}\n                    onClick={onReject}\n                    className=\"rounded-full text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400\"\n                  >\n                    Reject\n                  </Button>\n                ) : null}\n              </div>\n            )}\n          </AgentDisclosure>\n\n          {!interactive ? (\n            <p className=\"mt-1 text-sm text-muted-foreground\">\n              {result ?? statusLabel}\n            </p>\n          ) : null}\n        </div>\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/agents/approval-card/types.ts","type":"registry:component","target":"@components/agents/approval-card/types.ts","content":"// beui.dev/components/agents/approval-card\nimport type { ReactNode } from \"react\";\n\nexport type ApprovalCardStatus =\n  | \"pending\"\n  | \"submitting\"\n  | \"approved\"\n  | \"rejected\"\n  | \"changes-requested\"\n  | \"answered\";\n\nexport interface ApprovalCardOption {\n  value: string;\n  label: string;\n  disabled?: boolean;\n}\n\nexport interface ApprovalCardQuestion {\n  id: string;\n  title: ReactNode;\n  description?: ReactNode;\n  options?: ApprovalCardOption[];\n  multiple?: boolean;\n  autoAdvance?: boolean;\n  allowCustom?: boolean;\n  customPlaceholder?: string;\n}\n\nexport interface ApprovalCardAnswer {\n  selected: string[];\n  custom?: string;\n}\n\nexport type ApprovalCardAnswers = Record<string, ApprovalCardAnswer>;\n\nexport interface ApprovalCardProps {\n  title?: ReactNode;\n  description?: ReactNode;\n  children?: ReactNode;\n  questions?: ApprovalCardQuestion[];\n  status?: ApprovalCardStatus;\n  answers?: ApprovalCardAnswers;\n  defaultAnswers?: ApprovalCardAnswers;\n  onAnswersChange?: (answers: ApprovalCardAnswers) => void;\n  step?: number;\n  defaultStep?: number;\n  onStepChange?: (step: number) => void;\n  onSubmit?: (answers: ApprovalCardAnswers) => void;\n  onApprove?: () => void;\n  onReject?: () => void;\n  onRequestChanges?: () => void;\n  onDismiss?: () => void;\n  approveLabel?: ReactNode;\n  submitLabel?: ReactNode;\n  result?: ReactNode;\n  className?: string;\n}\n"},{"path":"components/agents/agent-disclosure.tsx","type":"registry:component","target":"@components/agents/agent-disclosure.tsx","content":"\"use client\";\n\nimport { motion, type HTMLMotionProps, useReducedMotion } from \"motion/react\";\nimport type { CSSProperties } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AgentDisclosureProps\n  extends Omit<HTMLMotionProps<\"div\">, \"animate\" | \"initial\"> {\n  open: boolean;\n  openHeight?: CSSProperties[\"height\"];\n}\n\n/** Shared transform-only reveal for collapsible agent content. */\nexport function AgentDisclosure({\n  open,\n  openHeight = \"auto\",\n  className,\n  style,\n  transition,\n  ...props\n}: AgentDisclosureProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <motion.div\n      {...props}\n      aria-hidden={!open}\n      inert={!open}\n      initial={false}\n      animate={\n        reduce\n          ? { opacity: open ? 1 : 0 }\n          : {\n              opacity: open ? 1 : 0,\n              clipPath: open ? \"inset(0 0 0% 0)\" : \"inset(0 0 100% 0)\",\n              y: open ? 0 : -4,\n            }\n      }\n      transition={\n        transition ?? {\n          duration: reduce ? 0 : open ? 0.22 : 0.14,\n          ease: EASE_OUT,\n        }\n      }\n      className={cn(\"overflow-hidden\", className)}\n      style={{\n        ...style,\n        height: open ? openHeight : 0,\n        pointerEvents: open ? undefined : \"none\",\n        transformOrigin: \"top\",\n      }}\n    />\n  );\n}\n"},{"path":"components/motion/action-swap-roll.tsx","type":"registry:component","target":"@components/motion/action-swap-roll.tsx","content":"\"use client\";\n\nimport {\n  ActionSwapButton,\n  ActionSwapIcon,\n  ActionSwapText,\n  type ActionSwapButtonProps,\n  type ActionSwapIconProps,\n  type ActionSwapTextProps,\n} from \"./action-swap\";\n\nexport type {\n  ActionSwapButtonSize,\n  ActionSwapButtonVariant,\n  ActionSwapItem,\n} from \"./action-swap\";\n\nexport type ActionSwapRollButtonProps = Omit<ActionSwapButtonProps, \"animation\">;\nexport type ActionSwapRollTextProps = Omit<ActionSwapTextProps, \"animation\">;\nexport type ActionSwapRollIconProps = Omit<ActionSwapIconProps, \"animation\">;\n\nexport function ActionSwapRollButton(props: ActionSwapRollButtonProps) {\n  return <ActionSwapButton {...props} animation=\"roll\" />;\n}\n\nexport function ActionSwapRollText(props: ActionSwapRollTextProps) {\n  return <ActionSwapText {...props} animation=\"roll\" />;\n}\n\nexport function ActionSwapRollIcon(props: ActionSwapRollIconProps) {\n  return <ActionSwapIcon {...props} animation=\"roll\" />;\n}\n"},{"path":"components/motion/button/index.tsx","type":"registry:component","target":"@components/motion/button/index.tsx","content":"export { Button } from \"./base\";\nexport type { ButtonProps, ButtonVariant, ButtonSize } from \"./base\";\n\nexport { StatefulButton } from \"./stateful\";\nexport type { StatefulButtonProps, ButtonState } from \"./stateful\";\n\nexport { MagneticButton } from \"./magnetic\";\nexport type { MagneticButtonProps } from \"./magnetic\";\n"},{"path":"components/motion/checkbox.tsx","type":"registry:component","target":"@components/motion/checkbox.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId } from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nconst CHECK_PATH = \"M5 13l4 4L19 7\";\nconst INDETERMINATE_PATH = \"M6 12h12\";\n\nexport interface CheckboxProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  disabled?: boolean;\n  indeterminate?: boolean;\n  label?: string;\n  className?: string;\n  id?: string;\n  \"aria-label\"?: string;\n}\n\nexport function Checkbox({\n  checked,\n  onCheckedChange,\n  disabled,\n  indeterminate,\n  label,\n  className,\n  id: idProp,\n  \"aria-label\": ariaLabel,\n}: CheckboxProps) {\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const reduce = useReducedMotion();\n  const showMark = checked || indeterminate;\n  const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;\n\n  return (\n    <label\n      htmlFor={id}\n      className={cn(\n        \"inline-flex items-center gap-3\",\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        className,\n      )}\n    >\n      <motion.button\n        id={id}\n        type=\"button\"\n        role=\"checkbox\"\n        aria-checked={indeterminate ? \"mixed\" : checked}\n        aria-label={ariaLabel}\n        disabled={disabled}\n        onClick={() => !disabled && onCheckedChange(!checked)}\n        whileTap={reduce || disabled ? undefined : { scale: 0.92 }}\n        transition={SPRING_PRESS}\n        data-state={\n          checked ? \"checked\" : indeterminate ? \"indeterminate\" : \"unchecked\"\n        }\n        className={cn(\n          \"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"disabled:cursor-not-allowed disabled:opacity-60\",\n          showMark\n            ? \"border-primary bg-primary text-primary-foreground\"\n            : \"border-muted-foreground/50 bg-background hover:border-muted-foreground\",\n        )}\n      >\n        <AnimatePresence initial={false}>\n          {showMark ? (\n            <motion.svg\n              key={indeterminate ? \"indeterminate\" : \"checked\"}\n              width=\"12\"\n              height=\"12\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={3}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}\n              animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, scale: 0.5, filter: \"blur(4px)\" }\n              }\n              transition={\n                reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }\n              }\n              aria-hidden\n            >\n              <title>{indeterminate ? \"Partially selected\" : \"Selected\"}</title>\n              <motion.path\n                d={path}\n                initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        duration: indeterminate ? 0.2 : 0.3,\n                        ease: EASE_OUT,\n                        delay: 0.04,\n                      }\n                }\n              />\n            </motion.svg>\n          ) : null}\n        </AnimatePresence>\n      </motion.button>\n      {label ? (\n        <span className={cn(\"select-none text-sm text-foreground\", disabled && \"opacity-60\")}>\n          {label}\n        </span>\n      ) : null}\n    </label>\n  );\n}\n"},{"path":"components/motion/input.tsx","type":"registry:component","target":"@components/motion/input.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type InputHTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InputClassNames = {\n  root?: string;\n  label?: string;\n  field?: string;\n  input?: string;\n  leftIcon?: string;\n  rightIcon?: string;\n  successIcon?: string;\n  errorMessage?: string;\n};\n\nexport interface InputProps extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"value\" | \"defaultValue\" | \"onChange\"\n> {\n  label?: string;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Truthy error triggers a shake, red border and (if a string) a message. */\n  error?: string | boolean;\n  success?: boolean;\n  leftIcon?: ReactNode;\n  rightIcon?: ReactNode;\n  className?: string;\n  classNames?: InputClassNames;\n}\n\nexport const Input = forwardRef<HTMLInputElement, InputProps>(function Input(\n  {\n    label,\n    value: valueProp,\n    defaultValue,\n    onChange,\n    onFocus,\n    onBlur,\n    error,\n    success,\n    leftIcon,\n    rightIcon,\n    className,\n    classNames,\n    disabled,\n    id: idProp,\n    type,\n    ...rest\n  },\n  ref,\n) {\n  const reactId = useId();\n  const id = idProp ?? reactId;\n  const reduce = useReducedMotion();\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const value = controlled ? (valueProp ?? \"\") : internal;\n\n  const [focused, setFocused] = useState(false);\n\n  const fieldRef = useRef<HTMLDivElement>(null);\n\n  const hasError = Boolean(error);\n  const errorMessage = typeof error === \"string\" ? error : null;\n\n  // Right edge shows the success check, otherwise the caller's right icon.\n  const rightSlot = success ? null : rightIcon;\n\n  // Shake the field when an error appears.\n  useEffect(() => {\n    if (!fieldRef.current || reduce || !hasError) return;\n    animate(\n      fieldRef.current,\n      { x: [0, -6, 6, -4, 4, -2, 0] },\n      { duration: 0.45 },\n    );\n  }, [hasError, reduce]);\n\n  const handleChange = (next: string) => {\n    if (!controlled) setInternal(next);\n    onChange?.(next);\n  };\n\n  return (\n    <div\n      className={cn(\"flex flex-col gap-1.5\", className, classNames?.root)}\n    >\n      {label ? (\n        <label\n          htmlFor={id}\n          className={cn(\n            \"px-1 text-sm font-medium text-foreground\",\n            classNames?.label,\n          )}\n        >\n          {label}\n        </label>\n      ) : null}\n\n      <div\n        ref={fieldRef}\n        data-state={\n          hasError\n            ? \"error\"\n            : success\n              ? \"success\"\n              : focused\n                ? \"focused\"\n                : \"idle\"\n        }\n        className={cn(\n          \"relative h-11 overflow-hidden rounded-full border transition-colors duration-200\",\n          \"border-border\",\n          focused && !hasError && \"border-foreground/40 ring-2 ring-ring/40\",\n          hasError && \"border-destructive ring-2 ring-destructive/25\",\n          disabled && \"opacity-60\",\n          classNames?.field,\n        )}\n      >\n        {leftIcon ? (\n          <span\n            className={cn(\n              \"pointer-events-none absolute left-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.leftIcon,\n            )}\n          >\n            {leftIcon}\n          </span>\n        ) : null}\n\n        <input\n          ref={ref}\n          id={id}\n          type={type}\n          value={value}\n          disabled={disabled}\n          aria-invalid={hasError || undefined}\n          aria-describedby={errorMessage ? `${id}-error` : undefined}\n          {...rest}\n          onChange={(e) => handleChange(e.target.value)}\n          onFocus={(event) => {\n            setFocused(true);\n            onFocus?.(event);\n          }}\n          onBlur={(event) => {\n            setFocused(false);\n            onBlur?.(event);\n          }}\n          className={cn(\n            \"peer h-full w-full bg-transparent text-base leading-6 text-foreground caret-foreground outline-none\",\n            \"placeholder:text-muted-foreground/60\",\n            leftIcon ? \"pl-10\" : \"pl-3.5\",\n            rightSlot || success ? \"pr-10\" : \"pr-3.5\",\n            disabled && \"cursor-not-allowed\",\n            classNames?.input,\n          )}\n        />\n\n        {success ? (\n          <motion.svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            className={cn(\n              \"absolute right-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-(--color-success)\",\n              classNames?.successIcon,\n            )}\n          >\n            <motion.path\n              d=\"M5 12.5l4.5 4.5L19 7.5\"\n              stroke=\"currentColor\"\n              strokeWidth={2.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: 0.35, ease: \"easeOut\" }}\n            />\n          </motion.svg>\n        ) : rightSlot ? (\n          <span\n            className={cn(\n              \"absolute right-0 top-0 flex h-full items-center text-muted-foreground [&_button]:grid [&_button]:size-11 [&_button]:place-items-center [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.rightIcon,\n            )}\n          >\n            {rightSlot}\n          </span>\n        ) : null}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {errorMessage ? (\n          <motion.p\n            id={`${id}-error`}\n            role=\"alert\"\n            initial={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            transition={{ duration: 0.2 }}\n            className={cn(\n              \"px-1 text-xs text-destructive\",\n              classNames?.errorMessage,\n            )}\n          >\n            {errorMessage}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n});\n"},{"path":"components/motion/radio.tsx","type":"registry:component","target":"@components/motion/radio.tsx","content":"\"use client\";\n\nimport { motion, MotionConfig, useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useId,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype RadioCtx = {\n  value: string;\n  setValue: (value: string) => void;\n  layoutId: string;\n};\n\nconst RadioCtx = createContext<RadioCtx | null>(null);\n\nfunction useRadioGroup() {\n  const ctx = useContext(RadioCtx);\n  if (!ctx) {\n    throw new Error(\"RadioGroupItem must be used inside <RadioGroup>\");\n  }\n  return ctx;\n}\n\nexport interface RadioGroupProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  children: ReactNode;\n  className?: string;\n  orientation?: \"vertical\" | \"horizontal\";\n}\n\nexport function RadioGroup({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  children,\n  className,\n  orientation = \"vertical\",\n}: RadioGroupProps) {\n  const [internal, setInternal] = useState(defaultValue);\n  const layoutId = useId();\n  const reduce = useReducedMotion();\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const setValue = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onValueChange?.(next);\n    },\n    [controlled, onValueChange],\n  );\n  const contextValue = useMemo(\n    () => ({ value: current, setValue, layoutId }),\n    [current, layoutId, setValue],\n  );\n\n  return (\n    <MotionConfig transition={reduce ? { duration: 0 } : SPRING_LAYOUT}>\n      <RadioCtx.Provider value={contextValue}>\n        <div\n          role=\"radiogroup\"\n          className={cn(\n            \"flex gap-3\",\n            orientation === \"vertical\" ? \"flex-col\" : \"flex-row flex-wrap\",\n            className,\n          )}\n        >\n          {children}\n        </div>\n      </RadioCtx.Provider>\n    </MotionConfig>\n  );\n}\n\nexport interface RadioGroupItemProps {\n  value: string;\n  label?: string;\n  disabled?: boolean;\n  className?: string;\n  id?: string;\n}\n\nexport function RadioGroupItem({\n  value,\n  label,\n  disabled,\n  className,\n  id: idProp,\n}: RadioGroupItemProps) {\n  const { value: groupValue, setValue, layoutId } = useRadioGroup();\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const reduce = useReducedMotion();\n  const selected = groupValue === value;\n\n  return (\n    <label\n      htmlFor={id}\n      className={cn(\n        \"inline-flex items-center gap-3\",\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        className,\n      )}\n    >\n      <motion.button\n        id={id}\n        type=\"button\"\n        role=\"radio\"\n        aria-checked={selected}\n        disabled={disabled}\n        onClick={() => !disabled && setValue(value)}\n        whileTap={reduce || disabled ? undefined : { scale: 0.92 }}\n        transition={SPRING_PRESS}\n        data-state={selected ? \"checked\" : \"unchecked\"}\n        className={cn(\n          \"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 outline-none transition-colors duration-200\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"disabled:cursor-not-allowed disabled:opacity-60\",\n          selected\n            ? \"border-primary\"\n            : \"border-muted-foreground/50 hover:border-muted-foreground\",\n        )}\n      >\n        {selected ? (\n          <motion.span\n            layoutId={layoutId}\n            className=\"absolute inset-1 rounded-full bg-primary\"\n            transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n          />\n        ) : null}\n      </motion.button>\n      {label ? (\n        <span className={cn(\"select-none text-sm text-foreground\", disabled && \"opacity-60\")}>\n          {label}\n        </span>\n      ) : null}\n    </label>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/action-swap.tsx","type":"registry:component","target":"@components/motion/action-swap.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from \"motion/react\";\nimport { useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ActionSwapItem = {\n  id: string;\n  label: ReactNode;\n  icon?: ReactNode;\n  ariaLabel?: string;\n};\n\nexport type ActionSwapButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\";\nexport type ActionSwapButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\nexport type ActionSwapAnimation = \"blur\" | \"roll\" | \"cascade\";\n\n/** Animations with a single-element variant set (cascade animates per letter). */\ntype CoreAnimation = \"blur\" | \"roll\";\n\nexport interface ActionSwapButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\" | \"onChange\"\n> {\n  items: ActionSwapItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, item: ActionSwapItem) => void;\n  variant?: ActionSwapButtonVariant;\n  size?: ActionSwapButtonSize;\n  animation?: ActionSwapAnimation;\n  iconOnly?: boolean;\n  cycle?: boolean;\n}\n\nexport interface ActionSwapTextProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nexport interface ActionSwapIconProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nconst BLUR_TRANSITION = { duration: 0.2, ease: \"easeInOut\" } as const;\nconst ROLL_TRANSITION = SPRING_SWAP;\nconst ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;\nconst SWAP_BLUR = \"blur(8px)\";\nconst ROLL_BLUR = \"blur(3px)\";\n\n// Cascade rolls the label one letter at a time, left to right. The leaving\n// and landing strings overlap as independent layers (no shared cells), so\n// proportional glyph widths never jitter. Exits cascade at half the enter\n// stagger so the tail of the old label lingers briefly.\nconst CASCADE_STAGGER = 0.025;\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst TEXT_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.94,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: \"90%\", filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: \"0%\",\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: \"-90%\",\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst ICON_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.25,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: 12, filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: -12,\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  outline: \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n  ghost: \"text-muted-foreground hover:bg-primary/5 hover:text-foreground\",\n};\n\nconst SIZE_CLASS: Record<ActionSwapButtonSize, string> = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-xs\",\n  md: \"h-10 gap-2 rounded-full px-4 text-sm\",\n  lg: \"h-12 gap-2.5 rounded-full px-5 text-base\",\n  icon: \"h-10 w-10 rounded-full\",\n};\n\nexport function ActionSwapText({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapTextProps) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));\n  });\n\n  // Cascade needs a plain string to split into letters; non-string content\n  // and reduced motion fall back to the closest single-element animation.\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = animation === \"cascade\" && label !== null && !reduce;\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\", className)}\n      style={{\n        width,\n        transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,\n      }}\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {children}\n      </span>\n      {cascade ? (\n        <>\n          {/* Letters are decorative fragments; readers get the whole label. */}\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, i) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.\n                  key={i}\n                  custom={i * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`${animation}-${value}`}\n            variants={TEXT_VARIANTS[coreAnimation]}\n            initial={reduce ? false : \"initial\"}\n            animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n            exit={reduce ? undefined : \"exit\"}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </span>\n  );\n}\n\nexport function ActionSwapIcon({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapIconProps) {\n  const reduce = useReducedMotion();\n  // Icons are single elements — cascade maps to its closest motion, roll.\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span className={cn(\"relative inline-grid shrink-0 place-items-center overflow-hidden\", className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={`${animation}-${value}`}\n          aria-hidden\n          variants={ICON_VARIANTS[coreAnimation]}\n          initial={reduce ? false : \"initial\"}\n          animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n          exit={reduce ? undefined : \"exit\"}\n          className=\"col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]\"\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ActionSwapButton({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  variant = \"secondary\",\n  size = \"md\",\n  animation = \"blur\",\n  iconOnly = size === \"icon\",\n  cycle = true,\n  className,\n  disabled,\n  onClick,\n  ...rest\n}: ActionSwapButtonProps) {\n  const reduce = useReducedMotion();\n  const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);\n  const currentValue = value ?? internalValue;\n  const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));\n  const activeItem = items[activeIndex] ?? items[0];\n  const hasIcon = items.some((item) => item.icon);\n  const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;\n\n  if (!activeItem) return null;\n\n  const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === \"string\" ? activeItem.label : undefined);\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"inline-flex items-center justify-center overflow-hidden font-medium transition-colors\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[size],\n        className,\n      )}\n      aria-label={accessibleLabel}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented || disabled || !cycle || !nextItem) return;\n        if (value === undefined) setInternalValue(nextItem.id);\n        onValueChange?.(nextItem.id, nextItem);\n      }}\n      {...rest}\n    >\n      {hasIcon ? (\n        <ActionSwapIcon value={activeItem.id} animation={animation} className=\"h-4 w-4\">\n          {activeItem.icon ?? null}\n        </ActionSwapIcon>\n      ) : null}\n      {!iconOnly ? (\n        <ActionSwapText value={activeItem.id} animation={animation}>\n          {activeItem.label}\n        </ActionSwapText>\n      ) : null}\n    </motion.button>\n  );\n}\n"},{"path":"components/motion/button/base.tsx","type":"registry:component","target":"@components/motion/button/base.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type HTMLMotionProps,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          const id = nextId.current++;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          ripple && \"relative overflow-hidden\",\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0.05, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n"},{"path":"components/motion/button/magnetic.tsx","type":"registry:component","target":"@components/motion/button/magnetic.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Magnetic } from \"../magnetic\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MagneticButtonProps extends ButtonProps {\n  /** Magnetic pull strength. Default 0.25. */\n  strength?: number;\n  /** Class applied to the magnetic wrapper. */\n  magneticClassName?: string;\n}\n\nexport const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(\n  { strength = 0.25, magneticClassName, children, ...rest },\n  ref,\n) {\n  return (\n    <Magnetic strength={strength} className={magneticClassName}>\n      <Button ref={ref} {...rest}>\n        {children}\n      </Button>\n    </Magnetic>\n  );\n});\n"},{"path":"components/motion/button/stateful.tsx","type":"registry:component","target":"@components/motion/button/stateful.tsx","content":"\"use client\";\n\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type ReactNode,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center overflow-hidden\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  // Measure strings with the same per-letter layout as the cascade. Measuring\n  // the whole string preserves kerning, which can make it narrower than the\n  // inline-block letters and clip the final glyph during the width animation.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/motion/magnetic.tsx","type":"registry:component","target":"@components/motion/magnetic.tsx","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"}]}