{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cta-section","type":"registry:block","title":"CTA Section","description":"Closing call-to-action section in a centered layout (large headline, two CTAs and a reassurance checklist inside a card) or a split layout (copy beside a visual or an email capture with a stateful submit), with a word-by-word headline reveal on scroll, a rotating gradient beam on the card border or a soft dotted field, and a layout that adapts to its own container width.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/cta-section.tsx","type":"registry:component","target":"@components/motion/cta-section.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/cta-section\n\nimport { Check, Send } from \"lucide-react\";\nimport { motion, type Transition, useReducedMotion } from \"motion/react\";\nimport {\n  type ElementType,\n  type FormEvent,\n  type ReactNode,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  Button,\n  ButtonLink,\n  type ButtonState,\n  StatefulButton,\n} from \"@/components/motion/button\";\nimport { TextReveal } from \"@/components/motion/text-reveal\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CtaAction {\n  label: ReactNode;\n  href?: string;\n  onClick?: () => void;\n  /** Trailing icon. */\n  icon?: ReactNode;\n}\n\nexport interface CtaEmailCapture {\n  /** Resolve to show success; reject to show the retry state. */\n  onSubscribe: (email: string) => Promise<void> | void;\n  placeholder?: string;\n  buttonLabel?: ReactNode;\n  successMessage?: ReactNode;\n  /** Small print under the field, e.g. \"Unsubscribe any time.\" */\n  note?: ReactNode;\n}\n\nexport interface CtaSectionProps {\n  /** \"centered\" stacks everything in one card; \"split\" puts copy left and a visual or email capture right. */\n  variant?: \"centered\" | \"split\";\n  eyebrow?: ReactNode;\n  /** Revealed word by word when the section scrolls into view. */\n  headline: string | string[];\n  description?: ReactNode;\n  primaryAction?: CtaAction;\n  secondaryAction?: CtaAction;\n  /** Short reassurance list (\"No credit card\", \"Cancel any time\"). */\n  checklist?: ReactNode[];\n  /** Email field with a stateful submit button. */\n  emailCapture?: CtaEmailCapture;\n  /** Right column for `variant=\"split\"` (mock UI, illustration). When omitted, the email capture takes that slot. */\n  visual?: ReactNode;\n  /** Card decoration: rotating gradient beam on the border, a dotted field, or nothing. Default \"beam\". */\n  background?: \"beam\" | \"dots\" | \"none\";\n  /** Heading element for the headline. Default \"h2\". */\n  headingAs?: ElementType;\n  className?: string;\n  cardClassName?: string;\n}\n\nconst WORD_STAGGER = 0.05;\nconst ENTER: Transition = { duration: 0.5, ease: EASE_OUT };\n\n// Beam sweep: decorative and slow so the border never pulls focus from the\n// copy; linear so the light travels at a constant speed around the card.\nconst BEAM_TRANSITION: Transition = {\n  duration: 8,\n  ease: \"linear\",\n  repeat: Number.POSITIVE_INFINITY,\n};\n\nfunction isEmail(value: string) {\n  return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value.trim());\n}\n\nfunction wordCount(text: string | string[]) {\n  return (Array.isArray(text) ? text.join(\" \") : text)\n    .split(/\\s+/)\n    .filter(Boolean).length;\n}\n\nfunction ActionButton({\n  action,\n  variant,\n}: {\n  action: CtaAction;\n  variant: \"primary\" | \"secondary\";\n}) {\n  const content = (\n    <>\n      {action.label}\n      {action.icon ?? null}\n    </>\n  );\n  return action.href ? (\n    <ButtonLink href={action.href} onClick={action.onClick} variant={variant}>\n      {content}\n    </ButtonLink>\n  ) : (\n    <Button onClick={action.onClick} variant={variant}>\n      {content}\n    </Button>\n  );\n}\n\nfunction EmailCapture({\n  capture,\n  align,\n}: {\n  capture: CtaEmailCapture;\n  align: \"center\" | \"left\";\n}) {\n  const id = useId();\n  const [email, setEmail] = useState(\"\");\n  const [state, setState] = useState<ButtonState>(\"idle\");\n  const [error, setError] = useState<string | null>(null);\n  const timer = useRef<number | undefined>(undefined);\n\n  useEffect(\n    () => () => {\n      if (timer.current) window.clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  const submit = async (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    if (state === \"loading\" || state === \"success\") return;\n    if (!isEmail(email)) {\n      setError(\"Enter a valid email address.\");\n      return;\n    }\n    setError(null);\n    setState(\"loading\");\n    try {\n      await capture.onSubscribe(email.trim());\n      setState(\"success\");\n    } catch {\n      setState(\"error\");\n      setError(\"Something went wrong. Try again.\");\n      timer.current = window.setTimeout(() => setState(\"idle\"), 2400);\n    }\n  };\n\n  const done = state === \"success\";\n\n  return (\n    <form\n      noValidate\n      onSubmit={submit}\n      className={cn(\n        \"flex w-full max-w-md flex-col gap-2\",\n        align === \"center\" && \"mx-auto\",\n      )}\n    >\n      <div className=\"flex flex-col gap-2 @sm:flex-row @sm:items-center\">\n        <label htmlFor={`${id}-email`} className=\"sr-only\">\n          Email address\n        </label>\n        <input\n          id={`${id}-email`}\n          type=\"email\"\n          inputMode=\"email\"\n          autoComplete=\"email\"\n          value={email}\n          disabled={done}\n          aria-invalid={error ? true : undefined}\n          aria-describedby={`${id}-status`}\n          onChange={(event) => {\n            setEmail(event.target.value);\n            if (error && isEmail(event.target.value)) setError(null);\n          }}\n          placeholder={capture.placeholder ?? \"you@company.com\"}\n          className={cn(\n            \"h-10 min-w-0 flex-1 rounded-full border bg-background px-4 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 disabled:opacity-60\",\n            error\n              ? \"border-destructive focus-visible:ring-destructive/30\"\n              : \"border-border focus-visible:border-border-strong focus-visible:ring-ring/40\",\n          )}\n        />\n        <StatefulButton\n          type=\"submit\"\n          state={state}\n          loadingText=\"Sending\"\n          successText=\"You're in\"\n          errorText=\"Retry\"\n          icon={<Send aria-hidden className=\"size-3.5\" />}\n          className=\"shrink-0\"\n        >\n          {capture.buttonLabel ?? \"Get started\"}\n        </StatefulButton>\n      </div>\n      <p\n        id={`${id}-status`}\n        aria-live=\"polite\"\n        className={cn(\n          \"min-h-5 text-xs\",\n          align === \"center\" && \"text-center\",\n          error ? \"text-destructive\" : \"text-muted-foreground\",\n        )}\n      >\n        {error ??\n          (done\n            ? (capture.successMessage ?? \"Thanks! Check your inbox.\")\n            : (capture.note ?? \"\"))}\n      </p>\n    </form>\n  );\n}\n\nfunction CardBackground({\n  background,\n  reduce,\n}: {\n  background: \"beam\" | \"dots\" | \"none\";\n  reduce: boolean | null;\n}) {\n  if (background === \"none\") return null;\n  if (background === \"dots\") {\n    return (\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 bg-[radial-gradient(var(--border-strong)_1px,transparent_1px)] bg-[size:18px_18px] [mask-image:radial-gradient(ellipse_70%_70%_at_50%_40%,black,transparent)]\"\n      />\n    );\n  }\n  // Beam: an oversized conic gradient spins behind the card; the card body\n  // sits 1px inside it, so only a moving sliver shows along the border.\n  return (\n    <motion.div\n      aria-hidden\n      className=\"pointer-events-none absolute top-1/2 left-1/2 aspect-square w-[max(200%,40rem)] bg-[conic-gradient(from_0deg,transparent_0deg,transparent_250deg,var(--chart-1)_300deg,var(--chart-2)_330deg,transparent_360deg)]\"\n      style={{ x: \"-50%\", y: \"-50%\" }}\n      initial={{ rotate: 0 }}\n      animate={reduce ? { rotate: 0 } : { rotate: 360 }}\n      transition={reduce ? { duration: 0 } : BEAM_TRANSITION}\n    />\n  );\n}\n\n/**\n * Closing call-to-action band. `centered` puts a large headline, supporting\n * copy, two CTAs and an optional reassurance checklist inside one card;\n * `split` sets the copy beside a visual or an email capture. The headline\n * reveals word by word on view and the rest follows. The card can carry a\n * rotating gradient beam along its border or a soft dotted field. Layout\n * reads the section's own container width.\n */\nexport function CtaSection({\n  variant = \"centered\",\n  eyebrow,\n  headline,\n  description,\n  primaryAction,\n  secondaryAction,\n  checklist,\n  emailCapture,\n  visual,\n  background = \"beam\",\n  headingAs = \"h2\",\n  className,\n  cardClassName,\n}: CtaSectionProps) {\n  const reduce = useReducedMotion();\n  const centered = variant === \"centered\";\n  const align = centered ? \"center\" : \"left\";\n  const after = Math.min(1, 0.15 + wordCount(headline) * WORD_STAGGER);\n\n  const enter = (delay: number) => ({\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 12 },\n    whileInView: reduce ? { opacity: 1 } : { opacity: 1, y: 0 },\n    viewport: { once: true, amount: 0.4 },\n    transition: { ...ENTER, delay },\n  });\n\n  // In split mode the email capture fills the right column unless a visual\n  // claims it; then it moves under the copy.\n  const captureInAside = !centered && !visual && Boolean(emailCapture);\n\n  const copy = (\n    <div\n      className={cn(\n        \"relative flex flex-col gap-4\",\n        centered ? \"items-center text-center\" : \"items-start text-left\",\n      )}\n    >\n      {eyebrow ? (\n        <motion.p\n          {...enter(0)}\n          className=\"text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground\"\n        >\n          {eyebrow}\n        </motion.p>\n      ) : null}\n      <TextReveal\n        as={headingAs}\n        text={headline}\n        whileInView\n        stagger={WORD_STAGGER}\n        delay={0.05}\n        blur={8}\n        yOffset=\"30%\"\n        className={cn(\n          \"text-balance font-display font-semibold leading-[1.1] tracking-tight text-foreground\",\n          centered\n            ? \"text-3xl @lg:text-4xl @3xl:text-5xl\"\n            : \"text-2xl @lg:text-3xl @3xl:text-4xl\",\n        )}\n      />\n      {description ? (\n        <motion.p\n          {...enter(after)}\n          className=\"max-w-xl text-pretty text-sm leading-6 text-muted-foreground @md:text-base @md:leading-7\"\n        >\n          {description}\n        </motion.p>\n      ) : null}\n      {primaryAction || secondaryAction ? (\n        <motion.div\n          {...enter(after + 0.08)}\n          className={cn(\n            \"mt-1 flex flex-wrap items-center gap-3\",\n            centered && \"justify-center\",\n          )}\n        >\n          {primaryAction ? (\n            <ActionButton action={primaryAction} variant=\"primary\" />\n          ) : null}\n          {secondaryAction ? (\n            <ActionButton action={secondaryAction} variant=\"secondary\" />\n          ) : null}\n        </motion.div>\n      ) : null}\n      {emailCapture && !captureInAside ? (\n        <motion.div {...enter(after + 0.12)} className=\"mt-1 w-full\">\n          <EmailCapture capture={emailCapture} align={align} />\n        </motion.div>\n      ) : null}\n      {checklist?.length ? (\n        <motion.ul\n          initial=\"hidden\"\n          whileInView=\"show\"\n          viewport={{ once: true, amount: 0.4 }}\n          variants={{\n            show: {\n              transition: { staggerChildren: 0.06, delayChildren: after + 0.16 },\n            },\n          }}\n          className={cn(\n            \"flex flex-wrap gap-x-5 gap-y-2 text-xs text-muted-foreground @md:text-sm\",\n            centered && \"justify-center\",\n          )}\n        >\n          {checklist.map((item, index) => (\n            <motion.li\n              // biome-ignore lint/suspicious/noArrayIndexKey: checklist is a static positional list.\n              key={index}\n              variants={{\n                hidden: reduce ? { opacity: 0 } : { opacity: 0, y: 6 },\n                show: { opacity: 1, y: 0, transition: ENTER },\n              }}\n              className=\"inline-flex items-center gap-1.5\"\n            >\n              <span className=\"grid size-4 place-items-center rounded-full bg-primary/10 text-primary\">\n                <Check aria-hidden className=\"size-3\" strokeWidth={3} />\n              </span>\n              {item}\n            </motion.li>\n          ))}\n        </motion.ul>\n      ) : null}\n    </div>\n  );\n\n  return (\n    <section className={cn(\"@container w-full\", className)}>\n      <div\n        className={cn(\n          \"relative isolate overflow-hidden rounded-3xl\",\n          background === \"beam\"\n            ? \"bg-border p-px\"\n            : \"border border-border bg-card\",\n          cardClassName,\n        )}\n      >\n        <CardBackground background={background} reduce={reduce} />\n        <div\n          className={cn(\n            \"relative overflow-hidden\",\n            background === \"beam\" && \"rounded-[calc(var(--radius-3xl)-1px)] bg-card\",\n            centered\n              ? \"px-6 py-12 @3xl:px-12 @3xl:py-16\"\n              : \"grid items-center gap-8 p-6 @2xl:grid-cols-[1.15fr_1fr] @3xl:p-10\",\n          )}\n        >\n          {/* Soft top glow so the card reads as lit from the beam side. */}\n          <div\n            aria-hidden\n            className=\"pointer-events-none absolute inset-x-0 top-0 h-2/3 bg-[radial-gradient(50%_60%_at_50%_0%,color-mix(in_oklab,var(--foreground)_6%,transparent),transparent)]\"\n          />\n          {copy}\n          {!centered && (visual || captureInAside) ? (\n            <motion.div\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.98 }}\n              whileInView={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}\n              viewport={{ once: true, amount: 0.3 }}\n              transition={{ ...ENTER, delay: 0.2 }}\n              className=\"relative @container\"\n            >\n              {visual ??\n                (emailCapture ? (\n                  <div className=\"rounded-2xl border border-border bg-background/60 p-5 shadow-sm\">\n                    <EmailCapture capture={emailCapture} align=\"left\" />\n                  </div>\n                ) : null)}\n            </motion.div>\n          ) : null}\n        </div>\n      </div>\n    </section>\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/text-reveal.tsx","type":"registry:component","target":"@components/motion/text-reveal.tsx","content":"\"use client\";\n\nimport { motion, type Transition, useInView, useReducedMotion } from \"motion/react\";\nimport { useRef, type ElementType, type ReactNode } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype SplitMode = \"word\" | \"char\";\n\nexport interface TextRevealProps {\n  text: string | string[];\n  as?: ElementType;\n  className?: string;\n  split?: SplitMode;\n  stagger?: number;\n  delay?: number;\n  blur?: number;\n  yOffset?: string | number;\n  spring?: { stiffness?: number; damping?: number; mass?: number };\n  once?: boolean;\n  whileInView?: boolean;\n  children?: ReactNode;\n}\n\nconst DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 };\n\ntype WordGroup = { text: string; trailing: string };\n\n/**\n * One tokenizer for both modes: a line becomes the words it is made of, each\n * carrying the whitespace that follows it. Word mode animates a group at a\n * time, char mode the characters inside one — so the two can't drift apart on\n * what counts as a word or where a space belongs. Runs of whitespace and tabs\n * survive as their own group rather than collapsing.\n */\nfunction toWordGroups(line: string): WordGroup[] {\n  const chunks = line.match(/\\S+\\s*|\\s+/g) ?? [];\n  return chunks.map((chunk) => {\n    const text = chunk.replace(/\\s+$/, \"\");\n    return { text, trailing: chunk.slice(text.length) };\n  });\n}\n\nexport function TextReveal({\n  text,\n  as: Comp = \"span\",\n  className,\n  split = \"word\",\n  stagger = 0.09,\n  delay = 0,\n  blur = 12,\n  yOffset = \"40%\",\n  spring,\n  once = true,\n  whileInView = false,\n  children,\n}: TextRevealProps) {\n  const ref = useRef<HTMLElement>(null);\n  const inView = useInView(ref, { once, amount: 0.4 });\n  const reduce = useReducedMotion();\n  const shouldAnimate = whileInView ? inView : true;\n\n  const lines = Array.isArray(text) ? text : [text];\n  const s = { ...DEFAULT_SPRING, ...spring };\n\n  let unitIndex = 0;\n  const lineCounts = new Map<string, number>();\n\n  return (\n    <Comp ref={ref} className={cn(\"block\", className)}>\n      {lines.map((line) => {\n        const lineCount = lineCounts.get(line) ?? 0;\n        lineCounts.set(line, lineCount + 1);\n        const lineKey = `${line}-${lineCount}`;\n        const unitCounts = new Map<string, number>();\n\n        const renderUnit = (unit: string) => {\n          const d = delay + unitIndex * stagger;\n          unitIndex += 1;\n          const unitCount = unitCounts.get(unit) ?? 0;\n          unitCounts.set(unit, unitCount + 1);\n          const unitKey = `${unit}-${unitCount}`;\n          const initial = reduce\n            ? { opacity: 0 }\n            : { y: yOffset, opacity: 0, filter: `blur(${blur}px)` };\n          const animate = shouldAnimate\n            ? reduce\n              ? { opacity: 1 }\n              : { y: 0, opacity: 1, filter: \"blur(0px)\" }\n            : initial;\n          const transition: Transition = reduce\n            ? { opacity: { duration: 0.25, ease: EASE_OUT, delay: d * 0.3 } }\n            : {\n                y: { type: \"spring\" as const, ...s, delay: d },\n                opacity: { duration: 0.7, ease: EASE_OUT, delay: d },\n                filter: { duration: 0.9, ease: EASE_OUT, delay: d },\n              };\n          return (\n            <motion.span\n              key={unitKey}\n              initial={initial}\n              animate={animate}\n              transition={transition}\n              // `whitespace-pre` is load-bearing: a unit's trailing space is\n              // inside an inline-block and would otherwise collapse to zero\n              // width, running every word together.\n              className=\"inline-block whitespace-pre will-change-transform\"\n            >\n              {unit}\n            </motion.span>\n          );\n        };\n\n        const groups = toWordGroups(line);\n        const groupCounts = new Map<string, number>();\n\n        return (\n          <span key={lineKey} className=\"block\">\n            {groups.map((group) => {\n              const whole = group.text + group.trailing;\n              // Characters animate one at a time, but each word (plus the\n              // space that follows it) sits in its own inline-block so a long\n              // line wraps between words instead of mid-word.\n              if (split !== \"char\") return renderUnit(whole);\n\n              const groupCount = groupCounts.get(whole) ?? 0;\n              groupCounts.set(whole, groupCount + 1);\n              return (\n                <span\n                  key={`${whole}-${groupCount}`}\n                  className=\"inline-block whitespace-pre\"\n                >\n                  {Array.from(whole).map((char) => renderUnit(char))}\n                </span>\n              );\n            })}\n          </span>\n        );\n      })}\n      {children}\n    </Comp>\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/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":"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":"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"}]}