{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"contact-form","type":"registry:block","title":"Contact Form","description":"Contact section with name, email, a topic select and an auto-growing message textarea with a live character counter, validation that only flags fields after blur, an async submit that shakes on error and morphs the card into a drawn-check thank-you panel, plus a side panel of contact methods.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/contact-form.tsx","type":"registry:component","target":"@components/motion/contact-form.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/contact-form\n\nimport {\n  Clock,\n  type LucideIcon,\n  Mail,\n  MapPin,\n  MessagesSquare,\n  User,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport { type FormEvent, type ReactNode, useId, useMemo, useRef, useState } from \"react\";\nimport { Button, StatefulButton } from \"@/components/motion/button\";\nimport { Input } from \"@/components/motion/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/motion/select\";\nimport { Textarea } from \"@/components/motion/textarea\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ContactValues = {\n  name: string;\n  email: string;\n  topic: string;\n  message: string;\n};\n\nexport type ContactErrors = Partial<Record<keyof ContactValues, string>>;\n\nexport type ContactTopic = { value: string; label: string };\n\nexport type ContactMethod = {\n  id: string;\n  icon: LucideIcon;\n  label: string;\n  value: ReactNode;\n  /** Makes the row a link (mailto:, tel:, https:). */\n  href?: string;\n};\n\nexport interface ContactFormProps {\n  defaultValues?: Partial<ContactValues>;\n  /** Called with valid values. Reject with a message to show it and shake the form. */\n  onSubmit?: (values: ContactValues) => void | Promise<void>;\n  validate?: (values: ContactValues) => ContactErrors;\n  topics?: ContactTopic[];\n  /** Side panel rows. Pass `[]` to hide the panel. */\n  methods?: ContactMethod[];\n  /** Character limit for the message (counter shows). Default 500. */\n  maxLength?: number;\n  /** Minimum message length. Default 10. */\n  minLength?: number;\n  title?: ReactNode;\n  description?: ReactNode;\n  className?: string;\n}\n\nconst DEFAULT_TOPICS: ContactTopic[] = [\n  { value: \"sales\", label: \"Sales\" },\n  { value: \"support\", label: \"Support\" },\n  { value: \"partnership\", label: \"Partnership\" },\n  { value: \"other\", label: \"Something else\" },\n];\n\nconst DEFAULT_METHODS: ContactMethod[] = [\n  {\n    id: \"email\",\n    icon: Mail,\n    label: \"Email\",\n    value: \"hello@example.com\",\n    href: \"mailto:hello@example.com\",\n  },\n  { id: \"chat\", icon: MessagesSquare, label: \"Live chat\", value: \"Mon–Fri, 9am–6pm\" },\n  { id: \"office\", icon: MapPin, label: \"Office\", value: \"100 Example St, Springfield\" },\n  { id: \"reply\", icon: Clock, label: \"Response time\", value: \"Within one business day\" },\n];\n\nconst EMPTY: ContactValues = { name: \"\", email: \"\", topic: \"\", message: \"\" };\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst SHAKE = { x: [0, -8, 8, -5, 5, -2, 0] };\n\nfunction messageOf(reason: unknown) {\n  if (reason instanceof Error && reason.message) return reason.message;\n  if (typeof reason === \"string\" && reason) return reason;\n  return \"Something went wrong. Try again.\";\n}\n\n/**\n * Contact section: name / email / topic `Select` / message `Textarea` with a\n * live counter, \"reward early, punish late\" validation (as in `SignUpForm`),\n * an async submit through `StatefulButton` that shakes on failure, and a\n * success panel the card morphs into. A side panel lists contact methods.\n */\nexport function ContactForm({\n  defaultValues,\n  onSubmit,\n  validate,\n  topics = DEFAULT_TOPICS,\n  methods = DEFAULT_METHODS,\n  maxLength = 500,\n  minLength = 10,\n  title = \"Get in touch\",\n  description = \"Tell us what you need and the right person will reply.\",\n  className,\n}: ContactFormProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const [values, setValues] = useState<ContactValues>({ ...EMPTY, ...defaultValues });\n  const [touched, setTouched] = useState<Partial<Record<keyof ContactValues, boolean>>>({});\n  const [status, setStatus] = useState<\"idle\" | \"loading\" | \"success\" | \"error\">(\"idle\");\n  const [formError, setFormError] = useState<string>();\n  const [sent, setSent] = useState<ContactValues | null>(null);\n  const shakeRef = useRef<HTMLDivElement>(null);\n\n  const errors = useMemo<ContactErrors>(() => {\n    if (validate) return validate(values);\n    const next: ContactErrors = {};\n    if (!values.name.trim()) next.name = \"Enter your name.\";\n    if (!values.email.trim()) next.email = \"Enter your email.\";\n    else if (!EMAIL_PATTERN.test(values.email))\n      next.email = \"That doesn't look like an email address.\";\n    if (!values.topic) next.topic = \"Pick a topic.\";\n    if (values.message.trim().length < minLength)\n      next.message = `Write at least ${minLength} characters.`;\n    return next;\n  }, [values, validate, minLength]);\n\n  const shown = (key: keyof ContactValues) => (touched[key] ? errors[key] : undefined);\n  const ok = (key: keyof ContactValues) =>\n    Boolean(touched[key]) && !errors[key] && Boolean(values[key]);\n\n  const set = (key: keyof ContactValues, next: string) => {\n    setValues((prev) => ({ ...prev, [key]: next }));\n    if (status === \"error\") setStatus(\"idle\");\n  };\n  const touch = (key: keyof ContactValues) =>\n    setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));\n\n  const shake = () => {\n    if (!reduce && shakeRef.current) {\n      animate(shakeRef.current, SHAKE, { duration: 0.42, ease: EASE_OUT });\n    }\n  };\n\n  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    setTouched({ name: true, email: true, topic: true, message: true });\n    if (Object.keys(errors).length > 0) {\n      shake();\n      return;\n    }\n    setFormError(undefined);\n    setStatus(\"loading\");\n    try {\n      await onSubmit?.(values);\n      setStatus(\"success\");\n      setSent(values);\n    } catch (reason) {\n      setStatus(\"error\");\n      setFormError(messageOf(reason));\n      shake();\n    }\n  };\n\n  const restart = () => {\n    setValues({ ...EMPTY, name: values.name, email: values.email });\n    setTouched({});\n    setStatus(\"idle\");\n    setSent(null);\n  };\n\n  const busy = status === \"loading\";\n  const topicErrorId = `${baseId}-topic-error`;\n  const topicLabelId = `${baseId}-topic-label`;\n  const offset = reduce ? 0 : 16;\n\n  return (\n    <div className={cn(\"@container w-full\", className)}>\n      <div\n        className={cn(\n          \"grid gap-4 rounded-3xl border border-border bg-card p-4 @lg:p-5\",\n          // Side column only when the card is wide enough (~768px); narrower\n          // containers move the methods into a compact grid under the form.\n          methods.length > 0 && \"@3xl:grid-cols-[minmax(0,1fr)_14rem]\",\n        )}\n      >\n        {/* The card resizes between the form and the thank-you panel; `layout`\n            is a layout job here by design, with content counter-corrected. */}\n        <motion.div\n          layout\n          transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n          className=\"min-w-0\"\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {sent ? (\n              <motion.div\n                key=\"sent\"\n                layout=\"position\"\n                role=\"status\"\n                initial={{ opacity: 0, y: offset, filter: reduce ? \"blur(0px)\" : \"blur(4px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, y: -offset, transition: { duration: 0.15 } }}\n                transition={{ duration: 0.3, ease: EASE_OUT }}\n                className=\"flex min-h-72 flex-col items-center justify-center gap-4 px-4 text-center\"\n              >\n                <motion.span\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  transition={reduce ? { duration: 0.2 } : { ...SPRING_SWAP, delay: 0.08 }}\n                  className=\"grid size-14 place-items-center rounded-full bg-success/15 text-success\"\n                >\n                  <svg viewBox=\"0 0 24 24\" className=\"size-7\" fill=\"none\" aria-hidden=\"true\">\n                    <motion.path\n                      d=\"M5 12.5l4.5 4.5L19 7.5\"\n                      stroke=\"currentColor\"\n                      strokeWidth={2.4}\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      initial={{ pathLength: reduce ? 1 : 0 }}\n                      animate={{ pathLength: 1 }}\n                      transition={{ duration: 0.4, delay: 0.2, ease: EASE_OUT }}\n                    />\n                  </svg>\n                </motion.span>\n                <div className=\"flex flex-col gap-1\">\n                  <h2 className=\"text-lg font-semibold tracking-tight text-foreground\">\n                    Message sent\n                  </h2>\n                  <p className=\"max-w-xs text-sm text-muted-foreground\">\n                    Thanks, {sent.name.split(\" \")[0]}. We&apos;ll reply to{\" \"}\n                    <span className=\"font-medium text-foreground\">{sent.email}</span> soon.\n                  </p>\n                </div>\n                <Button variant=\"secondary\" size=\"sm\" onClick={restart}>\n                  Send another message\n                </Button>\n              </motion.div>\n            ) : (\n              <motion.form\n                key=\"form\"\n                layout=\"position\"\n                noValidate\n                onSubmit={handleSubmit}\n                initial={{ opacity: 0, y: -offset }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{\n                  opacity: 0,\n                  y: -offset,\n                  filter: reduce ? \"blur(0px)\" : \"blur(4px)\",\n                  transition: { duration: 0.16 },\n                }}\n                transition={{ duration: 0.26, ease: EASE_OUT }}\n              >\n                <div ref={shakeRef} className=\"flex flex-col gap-3.5\">\n                  {title || description ? (\n                    <div className=\"flex flex-col gap-0.5\">\n                      {title ? (\n                        <h2 className=\"text-lg font-semibold tracking-tight text-foreground\">\n                          {title}\n                        </h2>\n                      ) : null}\n                      {description ? (\n                        <p className=\"text-sm text-muted-foreground\">{description}</p>\n                      ) : null}\n                    </div>\n                  ) : null}\n                  <div className=\"grid gap-3.5 @lg:grid-cols-2\">\n                    <Input\n                      label=\"Name\"\n                      autoComplete=\"name\"\n                      placeholder=\"Ada Lovelace\"\n                      leftIcon={<User />}\n                      disabled={busy}\n                      value={values.name}\n                      onChange={(next) => set(\"name\", next)}\n                      onBlur={() => touch(\"name\")}\n                      error={shown(\"name\")}\n                      success={ok(\"name\")}\n                    />\n                    <Input\n                      label=\"Email\"\n                      type=\"email\"\n                      inputMode=\"email\"\n                      autoComplete=\"email\"\n                      placeholder=\"you@example.com\"\n                      leftIcon={<Mail />}\n                      disabled={busy}\n                      value={values.email}\n                      onChange={(next) => set(\"email\", next)}\n                      onBlur={() => touch(\"email\")}\n                      error={shown(\"email\")}\n                      success={ok(\"email\")}\n                    />\n                  </div>\n                  <div className=\"relative z-20 flex flex-col gap-1.5\">\n                    <span id={topicLabelId} className=\"px-1 text-sm font-medium text-foreground\">\n                      Topic\n                    </span>\n                    <fieldset\n                      aria-labelledby={topicLabelId}\n                      aria-describedby={shown(\"topic\") ? topicErrorId : undefined}\n                      className=\"m-0 border-0 p-0\"\n                    >\n                      <Select\n                        value={values.topic || undefined}\n                        disabled={busy}\n                        onValueChange={(next) => {\n                          set(\"topic\", next);\n                          touch(\"topic\");\n                        }}\n                        onOpenChange={(open) => {\n                          if (!open) touch(\"topic\");\n                        }}\n                      >\n                        <SelectTrigger\n                          className={cn(\n                            \"h-11\",\n                            shown(\"topic\") && \"border-destructive ring-2 ring-destructive/25\",\n                          )}\n                        >\n                          <SelectValue placeholder=\"What's this about?\" />\n                        </SelectTrigger>\n                        <SelectContent>\n                          {topics.map((topic) => (\n                            <SelectItem key={topic.value} value={topic.value}>\n                              {topic.label}\n                            </SelectItem>\n                          ))}\n                        </SelectContent>\n                      </Select>\n                    </fieldset>\n                    <AnimatePresence initial={false}>\n                      {shown(\"topic\") ? (\n                        <motion.p\n                          id={topicErrorId}\n                          initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n                          animate={{ opacity: 1, y: 0 }}\n                          exit={{ opacity: 0 }}\n                          transition={{ duration: 0.18 }}\n                          className=\"px-1 text-xs text-destructive\"\n                        >\n                          {shown(\"topic\")}\n                        </motion.p>\n                      ) : null}\n                    </AnimatePresence>\n                  </div>\n                  <Textarea\n                    label=\"Message\"\n                    placeholder=\"How can we help?\"\n                    minRows={3}\n                    maxRows={6}\n                    maxLength={maxLength}\n                    disabled={busy}\n                    value={values.message}\n                    onChange={(next) => set(\"message\", next)}\n                    onBlur={() => touch(\"message\")}\n                    error={shown(\"message\")}\n                  />\n                  <AnimatePresence initial={false}>\n                    {formError && status === \"error\" ? (\n                      <motion.p\n                        role=\"alert\"\n                        initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n                        animate={{ opacity: 1, y: 0 }}\n                        exit={{ opacity: 0 }}\n                        transition={{ duration: 0.2 }}\n                        className=\"rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive\"\n                      >\n                        {formError}\n                      </motion.p>\n                    ) : null}\n                  </AnimatePresence>\n                  <StatefulButton\n                    type=\"submit\"\n                    state={status}\n                    loadingText=\"Sending\"\n                    successText=\"Sent\"\n                    errorText=\"Try again\"\n                    className=\"w-full @lg:w-fit @lg:self-end\"\n                  >\n                    Send message\n                  </StatefulButton>\n                </div>\n              </motion.form>\n            )}\n          </AnimatePresence>\n        </motion.div>\n\n        {methods.length > 0 ? (\n          <aside\n            aria-label=\"Other ways to reach us\"\n            className=\"grid content-start gap-1 rounded-2xl bg-muted/50 p-3 @md:grid-cols-2 @3xl:grid-cols-1\"\n          >\n            <p className=\"col-span-full px-2 pt-1 pb-1.5 text-xs font-medium text-muted-foreground\">\n              Other ways to reach us\n            </p>\n            {methods.map((method) => {\n              const Icon = method.icon;\n              const body = (\n                <>\n                  <span className=\"grid size-8 shrink-0 place-items-center rounded-xl border border-border bg-card text-foreground\">\n                    <Icon className=\"size-4\" aria-hidden />\n                  </span>\n                  <span className=\"min-w-0\">\n                    <span className=\"block text-xs text-muted-foreground\">{method.label}</span>\n                    <span className=\"block truncate text-sm font-medium text-foreground\">\n                      {method.value}\n                    </span>\n                  </span>\n                </>\n              );\n              const rowClass =\n                \"flex items-center gap-2.5 rounded-xl px-2 py-1.5 outline-none transition-colors\";\n              return method.href ? (\n                <a\n                  key={method.id}\n                  href={method.href}\n                  className={cn(\n                    rowClass,\n                    \"hover:bg-card focus-visible:ring-2 focus-visible:ring-ring\",\n                  )}\n                >\n                  {body}\n                </a>\n              ) : (\n                <div key={method.id} className={rowClass}>\n                  {body}\n                </div>\n              );\n            })}\n          </aside>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n"},{"path":"components/motion/button/index.tsx","type":"registry:component","target":"@components/motion/button/index.tsx","content":"export type {\n  ButtonLinkProps,\n  ButtonProps,\n  ButtonSize,\n  ButtonVariant,\n} from \"./base\";\nexport { Button, ButtonLink } from \"./base\";\nexport type { MagneticButtonProps } from \"./magnetic\";\nexport { MagneticButton } from \"./magnetic\";\nexport type { ButtonState, StatefulButtonProps } from \"./stateful\";\nexport { StatefulButton } from \"./stateful\";\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/select.tsx","type":"registry:component","target":"@components/motion/select.tsx","content":"\"use client\";\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport {\n  motion,\n  type Transition,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nconst INSTANT_TRANSITION: Transition = { duration: 0 };\n\n// Spring with bounce powers the unfold/separation; per-property timings in the\n// content choreograph it (see SelectContent). Mirrors bouncy-accordion's feel.\nconst CHEVRON_TRANSITION: Transition = { type: \"spring\", duration: 0.4, bounce: 0.3 };\n\nconst LIST_VARIANTS: Variants = {\n  hidden: {},\n  show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } },\n};\nconst ITEM_VARIANTS: Variants = {\n  hidden: { opacity: 0, y: -6, filter: \"blur(3px)\" },\n  show: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n};\n\ntype Placement = \"bottom\" | \"top\";\n\ninterface SelectContextValue {\n  value: string | undefined;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  select: (value: string) => void;\n  register: (value: string, label: string) => void;\n  unregister: (value: string) => void;\n  labelFor: (value: string | undefined) => string | undefined;\n  reduce: boolean;\n  triggerId: string;\n  listId: string;\n  disabled: boolean;\n  placement: Placement;\n  setPlacement: (p: Placement) => void;\n}\n\nconst SelectContext = createContext<SelectContextValue | null>(null);\n\nfunction useSelectContext(component: string) {\n  const ctx = useContext(SelectContext);\n  if (!ctx) throw new Error(`${component} must be used within <Select>`);\n  return ctx;\n}\n\nexport interface SelectProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Controlled open state of the panel. A layout that stacks selects can hold\n   * this to keep exactly one panel open — the panel is absolutely positioned\n   * inside its field, so two open at once paint over each other's options.\n   */\n  open?: boolean;\n  /** Uncontrolled initial open state. Default false. */\n  defaultOpen?: boolean;\n  /**\n   * Fires whenever the panel opens or closes. The panel is absolutely\n   * positioned inside the field, so a layout that stacks selects has to know\n   * which one is open to paint it above its neighbours.\n   */\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function Select({\n  value,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n  className,\n  children,\n}: SelectProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internal, setInternal] = useState(defaultValue);\n  const [labels, setLabels] = useState<Map<string, string>>(new Map());\n  const [placement, setPlacement] = useState<Placement>(\"bottom\");\n\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const openControlled = openProp !== undefined;\n  const open = openControlled ? openProp : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange, openControlled],\n  );\n\n  const select = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onValueChange?.(next);\n      setOpen(false);\n    },\n    [controlled, onValueChange, setOpen],\n  );\n\n  const register = useCallback((v: string, label: string) => {\n    setLabels((m) => (m.get(v) === label ? m : new Map(m).set(v, label)));\n  }, []);\n  const unregister = useCallback((v: string) => {\n    setLabels((m) => {\n      if (!m.has(v)) return m;\n      const next = new Map(m);\n      next.delete(v);\n      return next;\n    });\n  }, []);\n\n  // close on outside pointer / escape\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n    const onPointer = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node))\n        setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, setOpen]);\n\n  const ctx = useMemo<SelectContextValue>(\n    () => ({\n      value: current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labelFor: (v) => (v === undefined ? undefined : labels.get(v)),\n      reduce,\n      triggerId: `${baseId}-trigger`,\n      listId: `${baseId}-list`,\n      disabled,\n      placement,\n      setPlacement,\n    }),\n    [\n      current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labels,\n      reduce,\n      baseId,\n      disabled,\n      placement,\n    ],\n  );\n\n  return (\n    <SelectContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative\", className)}>\n        {children}\n      </div>\n    </SelectContext.Provider>\n  );\n}\n\nexport interface SelectTriggerProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectTrigger({ className, children }: SelectTriggerProps) {\n  const ctx = useSelectContext(\"SelectTrigger\");\n  const isTop = ctx.placement === \"top\";\n  // edge facing the panel flattens then rounds; the far edge stays rounded.\n  // All four corners are specified so none gets stranded when placement flips.\n  const kf = ctx.open ? [0, 0, 12] : [12, 0, 12];\n  const kfT: Transition = ctx.reduce\n    ? { duration: 0 }\n    : ctx.open\n      ? { duration: 0.6, times: [0, 0.4, 1], ease: EASE_OUT }\n      : { duration: 0.42, times: [0, 0.5, 1], ease: EASE_OUT };\n  return (\n    <motion.button\n      type=\"button\"\n      id={ctx.triggerId}\n      disabled={ctx.disabled}\n      aria-haspopup=\"listbox\"\n      aria-expanded={ctx.open}\n      aria-controls={ctx.listId}\n      onClick={() => ctx.setOpen(!ctx.open)}\n      // Gooey: the edge facing the panel snaps flat (panel attached) then rounds\n      // back once the panel pulls away — the two pinch apart.\n      initial={false}\n      animate={{\n        borderTopLeftRadius: isTop ? kf : 12,\n        borderTopRightRadius: isTop ? kf : 12,\n        borderBottomLeftRadius: isTop ? 12 : kf,\n        borderBottomRightRadius: isTop ? 12 : kf,\n      }}\n      transition={{\n        borderTopLeftRadius: isTop ? kfT : INSTANT_TRANSITION,\n        borderTopRightRadius: isTop ? kfT : INSTANT_TRANSITION,\n        borderBottomLeftRadius: isTop ? INSTANT_TRANSITION : kfT,\n        borderBottomRightRadius: isTop ? INSTANT_TRANSITION : kfT,\n      }}\n      className={cn(\n        \"relative z-10 flex w-full items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors\",\n        \"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        TOUCH_TARGET_CLASS,\n        className,\n      )}\n    >\n      {children}\n      <motion.span\n        aria-hidden\n        animate={{ rotate: ctx.open ? 180 : 0 }}\n        transition={ctx.reduce ? { duration: 0 } : CHEVRON_TRANSITION}\n        className=\"text-muted-foreground\"\n      >\n        <ChevronDown className=\"h-4 w-4\" />\n      </motion.span>\n    </motion.button>\n  );\n}\n\nexport interface SelectValueProps {\n  placeholder?: string;\n  className?: string;\n}\n\nexport function SelectValue({ placeholder, className }: SelectValueProps) {\n  const ctx = useSelectContext(\"SelectValue\");\n  const label = ctx.labelFor(ctx.value);\n  return (\n    <span\n      className={cn(label ? \"text-foreground\" : \"text-muted-foreground\", className)}\n    >\n      {label ?? placeholder ?? \"Select\"}\n    </span>\n  );\n}\n\nexport interface SelectContentProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectContent({ className, children }: SelectContentProps) {\n  const ctx = useSelectContext(\"SelectContent\");\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState(0);\n  const open = ctx.open;\n  const { setPlacement } = ctx;\n\n  useLayoutEffect(() => {\n    const node = innerRef.current;\n    if (!node) return;\n    const measure = () => setHeight(node.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  });\n\n  // On open, flip upward when there isn't room below and there's more above.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const trigger = document.getElementById(ctx.triggerId);\n    const node = innerRef.current;\n    if (!trigger || !node) return;\n    const rect = trigger.getBoundingClientRect();\n    const h = node.offsetHeight;\n    const below = window.innerHeight - rect.bottom;\n    const above = rect.top;\n    setPlacement(below < h + 16 && above > below ? \"top\" : \"bottom\");\n  }, [open, ctx.triggerId, setPlacement]);\n\n  // Specify EVERY corner + both margins each render. The near edge (facing the\n  // trigger) animates flat->round and the gap opens on that side; the far edge\n  // stays rounded and its margin pinned to 0. Setting all of them avoids a\n  // stranded square corner when the placement flips between opens.\n  const isTop = ctx.placement === \"top\";\n  const nearGap = open ? 8 : 0;\n  const nearRadius = open ? 12 : 0;\n\n  const gapT: Transition = open\n    ? { type: \"spring\", duration: 0.6, bounce: 0.5, delay: 0.12 }\n    : { type: \"spring\", duration: 0.3, bounce: 0.1 };\n  const radiusT: Transition = open\n    ? { duration: 0.3, ease: EASE_OUT, delay: 0.14 }\n    : { duration: 0.16, ease: EASE_OUT };\n\n  // Items stay mounted (open just animates the panel) so each item's label\n  // registration persists — otherwise the trigger would fall back to the\n  // placeholder the moment the panel closes.\n  return (\n    <motion.div\n      id={ctx.listId}\n      role=\"listbox\"\n      aria-labelledby={ctx.triggerId}\n      aria-hidden={!open}\n      inert={!open}\n      initial={false}\n      animate={\n        ctx.reduce\n          ? { opacity: open ? 1 : 0, height: open ? height : 0 }\n          : {\n              opacity: open ? 1 : 0,\n              height: open ? height : 0,\n              // gap opens on the side facing the trigger\n              marginTop: isTop ? 0 : nearGap,\n              marginBottom: isTop ? nearGap : 0,\n              // near corners go flat->round; far corners stay rounded\n              borderTopLeftRadius: isTop ? 12 : nearRadius,\n              borderTopRightRadius: isTop ? 12 : nearRadius,\n              borderBottomLeftRadius: isTop ? nearRadius : 12,\n              borderBottomRightRadius: isTop ? nearRadius : 12,\n            }\n      }\n      transition={\n        ctx.reduce\n          ? { duration: 0.12 }\n          : {\n              opacity: open\n                ? { duration: 0.18 }\n                : { duration: 0.16, delay: 0.12 },\n              height: open\n                ? { type: \"spring\", duration: 0.42, bounce: 0.14 }\n                : { duration: 0.26, ease: EASE_OUT, delay: 0.14 },\n              marginTop: isTop ? INSTANT_TRANSITION : gapT,\n              marginBottom: isTop ? gapT : INSTANT_TRANSITION,\n              borderTopLeftRadius: isTop ? INSTANT_TRANSITION : radiusT,\n              borderTopRightRadius: isTop ? INSTANT_TRANSITION : radiusT,\n              borderBottomLeftRadius: isTop ? radiusT : INSTANT_TRANSITION,\n              borderBottomRightRadius: isTop ? radiusT : INSTANT_TRANSITION,\n            }\n      }\n      style={{\n        transformOrigin: isTop ? \"bottom\" : \"top\",\n        overflow: \"hidden\",\n        pointerEvents: open ? \"auto\" : \"none\",\n      }}\n      // flush against the trigger, then separates into its own rounded pill;\n      // sits above or below depending on available space\n      className={cn(\n        \"absolute left-0 right-0 z-20 rounded-xl border border-border bg-background shadow-lg\",\n        isTop ? \"bottom-full\" : \"top-full\",\n        className,\n      )}\n    >\n      <motion.div\n        ref={innerRef}\n        variants={ctx.reduce ? undefined : LIST_VARIANTS}\n        initial={false}\n        animate={open ? \"show\" : \"hidden\"}\n        className=\"p-1\"\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n}\n\nexport interface SelectItemProps {\n  value: string;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectItem({\n  value,\n  disabled = false,\n  className,\n  children,\n}: SelectItemProps) {\n  const ctx = useSelectContext(\"SelectItem\");\n  const selected = ctx.value === value;\n  const label = typeof children === \"string\" ? children : value;\n\n  useLayoutEffect(() => {\n    ctx.register(value, label);\n    return () => ctx.unregister(value);\n  }, [ctx.register, ctx.unregister, value, label]);\n\n  return (\n    <motion.li variants={ctx.reduce ? undefined : ITEM_VARIANTS}>\n      <button\n        type=\"button\"\n        role=\"option\"\n        aria-selected={selected}\n        disabled={disabled}\n        onClick={() => ctx.select(value)}\n        className={cn(\n          \"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors\",\n          selected\n            ? \"bg-muted text-foreground\"\n            : \"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          className,\n        )}\n      >\n        {children}\n        {selected ? <Check className=\"h-3.5 w-3.5 shrink-0\" /> : null}\n      </button>\n    </motion.li>\n  );\n}\n"},{"path":"components/motion/textarea.tsx","type":"registry:component","target":"@components/motion/textarea.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type TextareaHTMLAttributes,\n} from \"react\";\nimport { EASE_OUT_CSS, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TextareaClassNames = {\n  root?: string;\n  label?: string;\n  description?: string;\n  field?: string;\n  textarea?: string;\n  counter?: string;\n  errorMessage?: string;\n};\n\nexport interface TextareaProps\n  extends Omit<\n    TextareaHTMLAttributes<HTMLTextAreaElement>,\n    \"value\" | \"defaultValue\" | \"onChange\" | \"rows\"\n  > {\n  label?: string;\n  /** Helper text under the label, linked through aria-describedby. */\n  description?: 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  /** Rows shown when empty; the field never shrinks below this. */\n  minRows?: number;\n  /** Rows the field grows to before it starts scrolling. */\n  maxRows?: number;\n  /** Shows the character counter. Defaults to true when `maxLength` is set. */\n  showCount?: boolean;\n  /** Fraction of `maxLength` at which the counter turns to the warning color. */\n  warnAt?: number;\n  className?: string;\n  classNames?: TextareaClassNames;\n}\n\nconst SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };\n\nexport const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(\n  function Textarea(\n    {\n      label,\n      description,\n      value: valueProp,\n      defaultValue,\n      onChange,\n      onFocus,\n      onBlur,\n      error,\n      minRows = 3,\n      maxRows = 8,\n      maxLength,\n      showCount,\n      warnAt = 0.9,\n      placeholder,\n      className,\n      classNames,\n      disabled,\n      id: idProp,\n      ...rest\n    },\n    forwardedRef,\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    const [height, setHeight] = useState<number | null>(null);\n    const [scrolls, setScrolls] = useState(false);\n\n    const fieldRef = useRef<HTMLDivElement>(null);\n    const counterRef = useRef<HTMLSpanElement>(null);\n    const mirrorRef = useRef<HTMLTextAreaElement>(null);\n    const innerRef = useRef<HTMLTextAreaElement | null>(null);\n\n    const setRefs = useCallback(\n      (node: HTMLTextAreaElement | null) => {\n        innerRef.current = node;\n        if (typeof forwardedRef === \"function\") forwardedRef(node);\n        else if (forwardedRef) forwardedRef.current = node;\n      },\n      [forwardedRef],\n    );\n\n    const hasError = Boolean(error);\n    const errorMessage = typeof error === \"string\" ? error : null;\n    const countVisible = showCount ?? maxLength !== undefined;\n    const count = value.length;\n    const ratio = maxLength ? count / maxLength : 0;\n    const atLimit = maxLength !== undefined && count >= maxLength;\n    const nearLimit = maxLength !== undefined && ratio >= warnAt && !atLimit;\n\n    // Measure the content with an invisible twin so the real field can\n    // transition between pixel heights (resetting it to `auto` would kill the\n    // transition). Height is this component's purpose, so it animates layout.\n    const measure = useCallback(() => {\n      const mirror = mirrorRef.current;\n      if (!mirror) return;\n      const style = getComputedStyle(mirror);\n      const lineHeight = Number.parseFloat(style.lineHeight) || 24;\n      const padY =\n        Number.parseFloat(style.paddingTop) +\n        Number.parseFloat(style.paddingBottom);\n      const min = lineHeight * minRows + padY;\n      const max = lineHeight * Math.max(minRows, maxRows) + padY;\n      const content = mirror.scrollHeight;\n      setHeight(Math.min(max, Math.max(min, content)));\n      setScrolls(content > max + 1);\n    }, [minRows, maxRows]);\n\n    useLayoutEffect(() => {\n      if (mirrorRef.current) mirrorRef.current.value = value || \" \";\n      measure();\n    }, [value, measure]);\n\n    // Width changes rewrap the text, so re-measure on resize.\n    useEffect(() => {\n      const field = fieldRef.current;\n      if (!field || typeof ResizeObserver === \"undefined\") return;\n      const observer = new ResizeObserver(() => measure());\n      observer.observe(field);\n      return () => observer.disconnect();\n    }, [measure]);\n\n    // Shake the field when an error appears.\n    useEffect(() => {\n      if (!fieldRef.current || reduce || !hasError) return;\n      animate(fieldRef.current, SHAKE, { duration: 0.45 });\n    }, [hasError, reduce]);\n\n    const nudgeCounter = useCallback(() => {\n      if (!counterRef.current || reduce) return;\n      animate(\n        counterRef.current,\n        { x: [0, -4, 4, -3, 3, 0] },\n        { duration: 0.35 },\n      );\n    }, [reduce]);\n\n    // Typing or pasting past `maxLength` is blocked natively; catch the\n    // attempt first so the counter can shake. Native listener because React's\n    // onBeforeInput misses paste and line breaks.\n    useEffect(() => {\n      const el = innerRef.current;\n      if (!el || maxLength === undefined) return;\n      const onBeforeInput = (event: InputEvent) => {\n        if (!event.inputType.startsWith(\"insert\")) return;\n        const data =\n          event.data ??\n          event.dataTransfer?.getData(\"text/plain\") ??\n          (event.inputType === \"insertLineBreak\" ? \"\\n\" : \"\");\n        const selected = el.selectionEnd - el.selectionStart;\n        if (el.value.length - selected + data.length > maxLength) {\n          nudgeCounter();\n        }\n      };\n      el.addEventListener(\"beforeinput\", onBeforeInput);\n      return () => el.removeEventListener(\"beforeinput\", onBeforeInput);\n    }, [maxLength, nudgeCounter]);\n\n    const handleChange = (next: string) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    };\n\n    const describedBy =\n      [\n        description ? `${id}-description` : null,\n        countVisible ? `${id}-count` : null,\n        errorMessage ? `${id}-error` : null,\n      ]\n        .filter(Boolean)\n        .join(\" \") || undefined;\n\n    const sharedText =\n      \"block w-full resize-none bg-transparent px-3.5 py-2.5 text-base leading-6\";\n\n    return (\n      <div\n        className={cn(\"flex flex-col gap-1.5\", className, classNames?.root)}\n      >\n        {label || description ? (\n          <div className=\"flex flex-col gap-0.5 px-1\">\n            {label ? (\n              <label\n                htmlFor={id}\n                className={cn(\n                  \"text-sm font-medium text-foreground\",\n                  classNames?.label,\n                )}\n              >\n                {label}\n              </label>\n            ) : null}\n            {description ? (\n              <p\n                id={`${id}-description`}\n                className={cn(\n                  \"text-xs text-muted-foreground\",\n                  classNames?.description,\n                )}\n              >\n                {description}\n              </p>\n            ) : null}\n          </div>\n        ) : null}\n\n        <div\n          ref={fieldRef}\n          data-state={hasError ? \"error\" : focused ? \"focused\" : \"idle\"}\n          className={cn(\n            \"relative overflow-hidden rounded-2xl 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          <textarea\n            ref={mirrorRef}\n            aria-hidden=\"true\"\n            tabIndex={-1}\n            readOnly\n            rows={1}\n            className={cn(\n              sharedText,\n              \"pointer-events-none invisible absolute inset-x-0 top-0 h-0 overflow-hidden\",\n              classNames?.textarea,\n            )}\n          />\n          <textarea\n            ref={setRefs}\n            id={id}\n            rows={minRows}\n            value={value}\n            disabled={disabled}\n            maxLength={maxLength}\n            placeholder={placeholder}\n            aria-invalid={hasError || undefined}\n            aria-describedby={describedBy}\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            style={{\n              height: height ?? undefined,\n              transition: reduce ? undefined : `height 160ms ${EASE_OUT_CSS}`,\n              ...rest.style,\n            }}\n            className={cn(\n              sharedText,\n              \"text-foreground caret-foreground outline-none\",\n              \"placeholder:text-muted-foreground/60\",\n              scrolls\n                ? \"overflow-y-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\"\n                : \"overflow-y-hidden\",\n              disabled && \"cursor-not-allowed\",\n              classNames?.textarea,\n            )}\n          />\n\n          {countVisible ? (\n            <div className=\"flex justify-end px-3.5 pb-2\">\n              <span\n                ref={counterRef}\n                id={`${id}-count`}\n                className={cn(\n                  \"inline-block text-xs tabular-nums text-muted-foreground transition-colors duration-200\",\n                  nearLimit && \"text-warning\",\n                  atLimit && \"text-destructive\",\n                  classNames?.counter,\n                )}\n              >\n                <motion.span\n                  key={atLimit ? \"limit\" : \"count\"}\n                  className=\"inline-block\"\n                  initial={reduce || !atLimit ? false : { scale: 1.2 }}\n                  animate={{ scale: 1 }}\n                  transition={SPRING_PRESS}\n                >\n                  {maxLength !== undefined ? `${count}/${maxLength}` : count}\n                </motion.span>\n                <span className=\"sr-only\">\n                  {maxLength !== undefined\n                    ? ` characters used, ${maxLength - count} remaining`\n                    : \" characters\"}\n                </span>\n              </span>\n            </div>\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);\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/button/base.tsx","type":"registry:component","target":"@components/motion/button/base.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\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 { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\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\nexport interface ButtonLinkProps extends Omit<\n  HTMLMotionProps<\"a\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\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          \"relative inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          // The ripple layer clips itself, so on a touch screen the button can\n          // stop clipping and let its enlarged hit area overhang.\n          ripple && \"overflow-hidden pointer-coarse:overflow-visible\",\n          TOUCH_TARGET_CLASS,\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\nexport const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(\n  function ButtonLink(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n\n    return (\n      <motion.a\n        ref={ref}\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"relative inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          TOUCH_TARGET_CLASS,\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.a>\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/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\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"}]}