{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"step-form","type":"registry:block","title":"Step Form","description":"Focused one-question-at-a-time multi-step form: segmented progress with a mono counter, directional slide transitions with container height morph, text and choice steps, shake-on-invalid validation with reserved error space, Enter-to-advance, and a drawn-check success state.","author":"UI Lab","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/step-form.tsx","type":"registry:component","target":"@components/motion/step-form.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/step-form\n\nimport { Loader2 } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  type AnimationControls,\n  motion,\n  useAnimationControls,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { Button } from \"@/components/motion/button\";\nimport { Input } from \"@/components/motion/input\";\nimport { EASE_OUT, SPRING_PANEL, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type StepFormStep =\n  | {\n      id: string;\n      kind: \"text\";\n      /** Small mono, uppercase, wide-tracking label above the title. */\n      eyebrow?: string;\n      title: string;\n      hint?: string;\n      placeholder?: string;\n      inputType?: \"text\" | \"email\";\n      required?: boolean;\n      /** Return an error message to block continuing, or null when valid. */\n      validate?: (value: string) => string | null;\n    }\n  | {\n      id: string;\n      kind: \"choice\";\n      eyebrow?: string;\n      title: string;\n      hint?: string;\n      options: { value: string; label: string; description?: string }[];\n      required?: boolean;\n    };\n\nexport interface StepFormProps {\n  steps: StepFormStep[];\n  onComplete?: (values: Record<string, string>) => void | Promise<void>;\n  /** Default \"All set\". */\n  successTitle?: string;\n  successMessage?: string;\n  /** Provide to show a ghost \"restart\" button on the success screen. */\n  onRestart?: () => void;\n  className?: string;\n}\n\ntype StepDirection = 1 | -1;\ntype FormStatus = \"active\" | \"submitting\" | \"success\";\n\n/** Horizontal travel for the directional step swap — spec-mandated 24px. */\nconst STEP_SLIDE_OFFSET = 24;\n/** Pause after picking an option, long enough to register the choice before advancing. */\nconst CHOICE_AUTO_ADVANCE_MS = 250;\n\nconst stepVariants: Variants = {\n  enter: (direction: StepDirection) => ({\n    opacity: 0,\n    x: direction * STEP_SLIDE_OFFSET,\n  }),\n  center: {\n    opacity: 1,\n    x: 0,\n    transition: { duration: 0.3, ease: EASE_OUT },\n  },\n  exit: (direction: StepDirection) => ({\n    opacity: 0,\n    x: direction * -STEP_SLIDE_OFFSET,\n    transition: { duration: 0.18, ease: EASE_OUT },\n  }),\n};\n\nfunction pad(value: number) {\n  return String(value).padStart(2, \"0\");\n}\n\n/** Mirrors the shake feel from input.tsx's own error effect. */\nfunction shake(controls: AnimationControls, reduce: boolean) {\n  if (reduce) return;\n  controls.start({\n    x: [0, -6, 6, -4, 4, -2, 0],\n    transition: { duration: 0.45 },\n  });\n}\n\nfunction SegmentedProgress({\n  steps,\n  activeIndex,\n  reduce,\n}: {\n  steps: StepFormStep[];\n  activeIndex: number;\n  reduce: boolean;\n}) {\n  return (\n    <div aria-hidden className=\"flex flex-1 items-center gap-1.5\">\n      {steps.map((step, index) => {\n        const filled = index <= activeIndex;\n        return (\n          <span\n            key={step.id}\n            className=\"h-1 flex-1 overflow-hidden rounded-full bg-border\"\n          >\n            <motion.span\n              className=\"block h-full w-full origin-left rounded-full bg-foreground\"\n              initial={false}\n              animate={{ scaleX: filled ? 1 : 0 }}\n              transition={\n                reduce\n                  ? { duration: 0.12 }\n                  : { duration: 0.35, ease: EASE_OUT }\n              }\n            />\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\nfunction SuccessCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <span className=\"grid h-14 w-14 shrink-0 place-items-center rounded-full bg-(--color-success)/12 text-(--color-success)\">\n      <svg viewBox=\"0 0 48 48\" fill=\"none\" className=\"h-7 w-7\" aria-hidden=\"true\">\n        <motion.circle\n          cx=\"24\"\n          cy=\"24\"\n          r=\"21\"\n          stroke=\"currentColor\"\n          strokeWidth={2.5}\n          initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n          animate={{ pathLength: 1 }}\n          transition={{ duration: 0.5, ease: EASE_OUT }}\n        />\n        <motion.path\n          d=\"M14 24.5l6.5 6.5L34 17\"\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={{\n            duration: 0.4,\n            ease: EASE_OUT,\n            delay: reduce ? 0 : 0.35,\n          }}\n        />\n      </svg>\n    </span>\n  );\n}\n\n/** Shared enter/animate/exit for the two outer views (form ⇄ success). */\nfunction outerViewMotionProps(reduce: boolean) {\n  return {\n    initial: reduce\n      ? { opacity: 0 }\n      : { opacity: 0, y: 8, filter: \"blur(4px)\" },\n    animate: reduce\n      ? { opacity: 1, transition: { duration: 0.18, ease: EASE_OUT } }\n      : {\n          opacity: 1,\n          y: 0,\n          filter: \"blur(0px)\",\n          transition: { duration: 0.24, ease: EASE_OUT },\n        },\n    exit: reduce\n      ? { opacity: 0, transition: { duration: 0.14, ease: EASE_OUT } }\n      : {\n          opacity: 0,\n          y: -8,\n          filter: \"blur(4px)\",\n          transition: { duration: 0.16, ease: EASE_OUT },\n        },\n  } as const;\n}\n\nexport function StepForm({\n  steps,\n  onComplete,\n  successTitle = \"All set\",\n  successMessage,\n  onRestart,\n  className,\n}: StepFormProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const fieldId = `${baseId}-field`;\n  const errorId = `${baseId}-error`;\n\n  const [stepIndex, setStepIndex] = useState(0);\n  const [direction, setDirection] = useState<StepDirection>(1);\n  const [values, setValues] = useState<Record<string, string>>({});\n  const [fieldError, setFieldError] = useState<string | null>(null);\n  const [status, setStatus] = useState<FormStatus>(\"active\");\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  const choiceShake = useAnimationControls();\n  const choiceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const skipFocusRef = useRef(true);\n\n  const currentStep = steps[stepIndex];\n  const isLastStep = stepIndex === steps.length - 1;\n  const currentValue = currentStep ? (values[currentStep.id] ?? \"\") : \"\";\n  const submitting = status === \"submitting\";\n\n  const clearChoiceTimer = () => {\n    if (choiceTimerRef.current !== null) {\n      clearTimeout(choiceTimerRef.current);\n      choiceTimerRef.current = null;\n    }\n  };\n\n  // Cancel any pending auto-advance if the form unmounts mid-pause.\n  useEffect(() => {\n    return () => {\n      if (choiceTimerRef.current !== null) clearTimeout(choiceTimerRef.current);\n    };\n  }, []);\n\n  // Focus the field on every step arrival except the very first paint, so\n  // landing on the form never steals focus but advancing keeps you typing.\n  // Re-reads `steps[stepIndex]` instead of closing over `currentStep` so a\n  // run between two consecutive \"text\" steps still re-triggers on index\n  // change (kind alone wouldn't differ).\n  useEffect(() => {\n    if (skipFocusRef.current) {\n      skipFocusRef.current = false;\n      return;\n    }\n    const step = steps[stepIndex];\n    if (status !== \"active\" || step?.kind !== \"text\") return;\n    const timeoutId = setTimeout(\n      () => inputRef.current?.focus({ preventScroll: true }),\n      reduce ? 0 : 260,\n    );\n    return () => clearTimeout(timeoutId);\n  }, [stepIndex, status, reduce, steps]);\n\n  if (!currentStep) return null;\n\n  const goToStep = (nextIndex: number, dir: StepDirection) => {\n    clearChoiceTimer();\n    setFieldError(null);\n    setDirection(dir);\n    setStepIndex(nextIndex);\n  };\n\n  const goBack = () => {\n    if (stepIndex === 0) return;\n    goToStep(stepIndex - 1, -1);\n  };\n\n  const handleComplete = async () => {\n    setStatus(\"submitting\");\n    try {\n      await onComplete?.(values);\n      setStatus(\"success\");\n    } catch {\n      // No error UX is specified for a rejected onComplete — fall back to\n      // the editable step so the user can retry instead of getting stuck.\n      setStatus(\"active\");\n    }\n  };\n\n  const advance = () => {\n    if (isLastStep) {\n      void handleComplete();\n      return;\n    }\n    goToStep(stepIndex + 1, 1);\n  };\n\n  const handleValueChange = (next: string) => {\n    setValues((prev) => ({ ...prev, [currentStep.id]: next }));\n    setFieldError(null);\n  };\n\n  const handleContinue = () => {\n    if (submitting) return;\n\n    if (currentStep.kind === \"text\") {\n      const trimmed = currentValue.trim();\n      if (currentStep.required && trimmed.length === 0) {\n        setFieldError(\"This field is required.\");\n        return;\n      }\n      const validationError =\n        trimmed.length > 0 ? currentStep.validate?.(currentValue) : null;\n      if (validationError) {\n        setFieldError(validationError);\n        return;\n      }\n    } else if (currentStep.required && !currentValue) {\n      setFieldError(\"Choose an option to continue.\");\n      shake(choiceShake, !!reduce);\n      return;\n    }\n\n    advance();\n  };\n\n  const handleChoiceSelect = (value: string) => {\n    if (submitting) return;\n    handleValueChange(value);\n    clearChoiceTimer();\n    choiceTimerRef.current = setTimeout(() => {\n      choiceTimerRef.current = null;\n      advance();\n    }, CHOICE_AUTO_ADVANCE_MS);\n  };\n\n  const titleClassName = cn(\n    \"block text-2xl font-semibold leading-snug text-foreground\",\n    currentStep.eyebrow && \"mt-2\",\n  );\n\n  return (\n    <motion.div\n      layout\n      transition={SPRING_PANEL}\n      className={cn(\n        \"w-full max-w-lg overflow-hidden rounded-3xl border border-border bg-card p-6 shadow-sm will-change-transform sm:p-8\",\n        className,\n      )}\n    >\n      <motion.div layout=\"position\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {status === \"success\" ? (\n            <motion.div\n              key=\"success\"\n              {...outerViewMotionProps(!!reduce)}\n              className=\"flex flex-col items-center px-2 py-4 text-center\"\n            >\n              <SuccessCheck reduce={!!reduce} />\n              <h3 className=\"mt-5 text-xl font-semibold text-foreground\">\n                {successTitle}\n              </h3>\n              {successMessage ? (\n                <p className=\"mt-2 max-w-sm text-sm text-muted-foreground\">\n                  {successMessage}\n                </p>\n              ) : null}\n              {onRestart ? (\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className=\"mt-6\"\n                  onClick={onRestart}\n                >\n                  Restart\n                </Button>\n              ) : null}\n            </motion.div>\n          ) : (\n            <motion.div key=\"form\" {...outerViewMotionProps(!!reduce)}>\n              <div className=\"flex items-center gap-4\">\n                <SegmentedProgress\n                  steps={steps}\n                  activeIndex={stepIndex}\n                  reduce={!!reduce}\n                />\n                <span className=\"shrink-0 font-mono text-xs tabular-nums text-muted-foreground\">\n                  <span className=\"text-foreground\">\n                    {pad(stepIndex + 1)}\n                  </span>\n                  <span className=\"px-0.5\">/</span>\n                  {pad(steps.length)}\n                </span>\n              </div>\n\n              <div className=\"mt-8\">\n                <AnimatePresence mode=\"wait\" custom={direction} initial={false}>\n                  <motion.div\n                    key={currentStep.id}\n                    custom={direction}\n                    variants={stepVariants}\n                    initial={reduce ? { opacity: 0 } : \"enter\"}\n                    animate={\n                      reduce\n                        ? { opacity: 1, transition: { duration: 0.18, ease: EASE_OUT } }\n                        : \"center\"\n                    }\n                    exit={\n                      reduce\n                        ? { opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }\n                        : \"exit\"\n                    }\n                  >\n                    <div className=\"flex flex-col gap-2\">\n                      {currentStep.eyebrow ? (\n                        <p className=\"font-mono text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground\">\n                          {currentStep.eyebrow}\n                        </p>\n                      ) : null}\n\n                      {currentStep.kind === \"text\" ? (\n                        <label htmlFor={fieldId} className={titleClassName}>\n                          {currentStep.title}\n                        </label>\n                      ) : (\n                        <h3 className={titleClassName}>{currentStep.title}</h3>\n                      )}\n\n                      {currentStep.hint ? (\n                        <p className=\"text-sm text-muted-foreground\">\n                          {currentStep.hint}\n                        </p>\n                      ) : null}\n                    </div>\n\n                    <div className=\"mt-6\">\n                      {currentStep.kind === \"text\" ? (\n                        <Input\n                          ref={inputRef}\n                          id={fieldId}\n                          type={currentStep.inputType === \"email\" ? \"email\" : \"text\"}\n                          inputMode={currentStep.inputType === \"email\" ? \"email\" : \"text\"}\n                          placeholder={currentStep.placeholder}\n                          value={currentValue}\n                          onChange={handleValueChange}\n                          onKeyDown={(event) => {\n                            if (event.key === \"Enter\") {\n                              event.preventDefault();\n                              handleContinue();\n                            }\n                          }}\n                          error={Boolean(fieldError)}\n                          aria-describedby={fieldError ? errorId : undefined}\n                          disabled={submitting}\n                        />\n                      ) : (\n                        <motion.div\n                          animate={choiceShake}\n                          className=\"flex flex-col gap-2.5\"\n                        >\n                          {currentStep.options.map((option) => {\n                            const selected = currentValue === option.value;\n                            return (\n                              <button\n                                key={option.value}\n                                type=\"button\"\n                                aria-pressed={selected}\n                                disabled={submitting}\n                                onClick={() => handleChoiceSelect(option.value)}\n                                className={cn(\n                                  \"flex items-center justify-between gap-4 rounded-2xl border px-4 py-3.5 text-left transition-colors disabled:pointer-events-none disabled:opacity-60\",\n                                  selected\n                                    ? \"border-foreground bg-primary/5\"\n                                    : \"border-border hover:bg-primary/5\",\n                                )}\n                              >\n                                <span className=\"min-w-0\">\n                                  <span className=\"block text-sm font-medium text-foreground\">\n                                    {option.label}\n                                  </span>\n                                  {option.description ? (\n                                    <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n                                      {option.description}\n                                    </span>\n                                  ) : null}\n                                </span>\n                                <span\n                                  aria-hidden\n                                  className={cn(\n                                    \"grid h-5 w-5 shrink-0 place-items-center rounded-full border\",\n                                    selected ? \"border-foreground\" : \"border-border\",\n                                  )}\n                                >\n                                  <motion.span\n                                    className=\"h-2 w-2 rounded-full bg-foreground\"\n                                    initial={false}\n                                    animate={{ scale: selected ? 1 : 0 }}\n                                    transition={\n                                      reduce ? { duration: 0.12 } : SPRING_SWAP\n                                    }\n                                  />\n                                </span>\n                              </button>\n                            );\n                          })}\n                        </motion.div>\n                      )}\n                    </div>\n\n                    <div className=\"mt-2 min-h-[1.25rem] px-1\">\n                      <AnimatePresence initial={false}>\n                        {fieldError ? (\n                          <motion.p\n                            key=\"error\"\n                            id={errorId}\n                            role=\"alert\"\n                            initial={{ opacity: 0 }}\n                            animate={{ opacity: 1 }}\n                            exit={{ opacity: 0 }}\n                            transition={{ duration: 0.15 }}\n                            className=\"text-xs text-destructive\"\n                          >\n                            {fieldError}\n                          </motion.p>\n                        ) : null}\n                      </AnimatePresence>\n                    </div>\n                  </motion.div>\n                </AnimatePresence>\n              </div>\n\n              <div className=\"mt-8 flex items-center gap-3\">\n                <AnimatePresence initial={false}>\n                  {stepIndex > 0 ? (\n                    <motion.span\n                      key=\"back\"\n                      initial={{ opacity: 0 }}\n                      animate={{ opacity: 1 }}\n                      exit={{ opacity: 0 }}\n                      transition={{ duration: 0.15 }}\n                    >\n                      <Button\n                        type=\"button\"\n                        variant=\"ghost\"\n                        onClick={goBack}\n                        disabled={submitting}\n                      >\n                        Back\n                      </Button>\n                    </motion.span>\n                  ) : null}\n                </AnimatePresence>\n\n                <Button\n                  type=\"button\"\n                  onClick={handleContinue}\n                  disabled={submitting}\n                  className=\"ml-auto\"\n                >\n                  {submitting ? (\n                    <span className=\"inline-flex items-center gap-2\">\n                      <Loader2 className=\"h-4 w-4 animate-spin\" aria-hidden />\n                      Submitting…\n                    </span>\n                  ) : isLastStep ? (\n                    \"Complete\"\n                  ) : (\n                    \"Continue\"\n                  )}\n                </Button>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </motion.div>\n    </motion.div>\n  );\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/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-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground [&_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":"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"},{"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/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          setRipples((prev) => [\n            ...prev,\n            {\n              id: nextId.current++,\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, 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 {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  forwardRef,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ReactNode,\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  // Width is set instantly from the measurer; the parent's single `layout`\n  // animation smooths the resize (text + icons together) so nothing competes.\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        {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"}]}