{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hero-section","type":"registry:block","title":"Hero Section","description":"Landing hero with a New pill badge, a headline revealed word by word with a spring blur-in, supporting copy that follows, primary and secondary CTAs, an install command chip with an animated copy button, an optional media slot that tilts up into place on view, and a CSS-gradient grid background with sweeping beams, centered or left aligned.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/hero-section.tsx","type":"registry:component","target":"@components/motion/hero-section.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/hero-section\n\nimport { ArrowRight } from \"lucide-react\";\nimport { motion, type Transition, useReducedMotion } from \"motion/react\";\nimport type { ElementType, ReactNode } from \"react\";\nimport { Button, ButtonLink } from \"@/components/motion/button\";\nimport { CopyButton } from \"@/components/motion/copy-button\";\nimport { TextReveal } from \"@/components/motion/text-reveal\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface HeroAction {\n  label: ReactNode;\n  href?: string;\n  onClick?: () => void;\n  /** Trailing icon. */\n  icon?: ReactNode;\n}\n\nexport interface HeroBadge {\n  label: ReactNode;\n  /** Pill before the label. Default \"New\"; null hides it. */\n  pill?: ReactNode;\n  href?: string;\n  onClick?: () => void;\n}\n\nexport interface HeroSectionProps {\n  /** Revealed word by word with a blur-in. */\n  headline: string | string[];\n  description?: ReactNode;\n  badge?: HeroBadge;\n  primaryAction?: HeroAction;\n  secondaryAction?: HeroAction;\n  /** Shell command shown in a chip with a copy button. */\n  installCommand?: string;\n  /** Screenshot, video or mock UI below the copy. Tilts in on view. */\n  media?: ReactNode;\n  align?: \"center\" | \"left\";\n  /** CSS-only animated grid with sweeping beams, or nothing. Default \"grid\". */\n  background?: \"grid\" | \"none\";\n  /** Heading element for the headline. Default \"h1\". */\n  headingAs?: ElementType;\n  className?: string;\n  mediaClassName?: string;\n}\n\nconst WORD_STAGGER = 0.06;\n\nconst ENTER: Transition = { duration: 0.5, ease: EASE_OUT };\n\n// Media tilt-in: a heavier, slower spring than the UI tokens because it moves\n// a large surface through a 3D tilt; the snappier tokens read as a jolt here.\nconst MEDIA_SPRING: Transition = {\n  type: \"spring\",\n  stiffness: 110,\n  damping: 22,\n  mass: 1,\n};\n\n// Beam sweep: decorative, slow and linear so the eye never locks onto it.\nconst BEAM_TRANSITION: Transition = {\n  duration: 7,\n  ease: \"linear\",\n  repeat: Number.POSITIVE_INFINITY,\n  repeatDelay: 2,\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: HeroAction;\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 HeroBackground({ reduce }: { reduce: boolean | null }) {\n  return (\n    <div\n      aria-hidden\n      className=\"pointer-events-none absolute inset-0 overflow-hidden [mask-image:radial-gradient(ellipse_70%_60%_at_50%_0%,black_40%,transparent_100%)]\"\n    >\n      {/* 40px grid drawn with two repeating hairline gradients. */}\n      <div className=\"absolute inset-0 bg-[linear-gradient(to_right,var(--border-strong)_1px,transparent_1px),linear-gradient(to_bottom,var(--border-strong)_1px,transparent_1px)] bg-[size:40px_40px]\" />\n      {/* Soft glow at the top. */}\n      <div className=\"absolute inset-x-0 top-0 h-2/3 bg-[radial-gradient(50%_60%_at_50%_0%,color-mix(in_oklab,var(--foreground)_9%,transparent),transparent)]\" />\n      {reduce ? null : (\n        <>\n          {/* Beams ride grid lines (multiples of 40px) and only translate. */}\n          <motion.div\n            className=\"absolute top-[120px] left-0 h-px w-1/3 bg-[linear-gradient(to_right,transparent,var(--foreground),transparent)] opacity-40\"\n            initial={{ x: \"-100%\" }}\n            animate={{ x: \"300%\" }}\n            transition={BEAM_TRANSITION}\n          />\n          <motion.div\n            className=\"absolute top-0 left-[calc(50%+80px)] h-1/2 w-px bg-[linear-gradient(to_bottom,transparent,var(--foreground),transparent)] opacity-30\"\n            initial={{ y: \"-100%\" }}\n            animate={{ y: \"200%\" }}\n            transition={{ ...BEAM_TRANSITION, delay: 3 }}\n          />\n        </>\n      )}\n    </div>\n  );\n}\n\n/**\n * Landing hero: an eyebrow badge with a \"New\" pill, a headline revealed word\n * by word with a blur-in, supporting copy, primary/secondary CTAs, an optional\n * install command with copy, and an optional media slot that tilts up into\n * place on view, over a CSS-only animated grid. Type scales with the\n * section's own container width.\n */\nexport function HeroSection({\n  headline,\n  description,\n  badge,\n  primaryAction,\n  secondaryAction,\n  installCommand,\n  media,\n  align = \"center\",\n  background = \"grid\",\n  headingAs = \"h1\",\n  className,\n  mediaClassName,\n}: HeroSectionProps) {\n  const reduce = useReducedMotion();\n  const centered = align === \"center\";\n  // Supporting content enters as the headline finishes.\n  const after = Math.min(1.2, 0.15 + wordCount(headline) * WORD_STAGGER);\n\n  const enter = (delay: number) => ({\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 12 },\n    animate: reduce ? { opacity: 1 } : { opacity: 1, y: 0 },\n    transition: { ...ENTER, delay },\n  });\n\n  const badgeInner = badge ? (\n    <>\n      {badge.pill === null ? null : (\n        <span className=\"rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold text-primary-foreground\">\n          {badge.pill ?? \"New\"}\n        </span>\n      )}\n      <span className=\"truncate\">{badge.label}</span>\n      <ArrowRight\n        aria-hidden\n        className=\"size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 group-hover:translate-x-0.5 motion-reduce:transition-none\"\n      />\n    </>\n  ) : null;\n  const badgeClass =\n    \"group inline-flex max-w-full items-center gap-2 rounded-full border border-border bg-card/80 py-1 pr-3 pl-1 text-xs font-medium text-foreground shadow-xs backdrop-blur transition-colors hover:border-border-strong outline-none focus-visible:ring-2 focus-visible:ring-ring/50\";\n\n  return (\n    <section\n      className={cn(\n        \"@container relative isolate w-full overflow-hidden\",\n        className,\n      )}\n    >\n      {background === \"grid\" ? <HeroBackground reduce={reduce} /> : null}\n\n      <div\n        className={cn(\n          \"relative mx-auto flex max-w-3xl flex-col gap-5 px-5 pt-12 pb-10 @3xl:pt-20\",\n          centered ? \"items-center text-center\" : \"items-start text-left\",\n          !centered && \"mx-0 max-w-2xl\",\n        )}\n      >\n        {badge ? (\n          <motion.div {...enter(0)}>\n            {badge.href ? (\n              <a href={badge.href} onClick={badge.onClick} className={badgeClass}>\n                {badgeInner}\n              </a>\n            ) : badge.onClick ? (\n              <button type=\"button\" onClick={badge.onClick} className={badgeClass}>\n                {badgeInner}\n              </button>\n            ) : (\n              <span className={badgeClass}>{badgeInner}</span>\n            )}\n          </motion.div>\n        ) : null}\n\n        <TextReveal\n          as={headingAs}\n          text={headline}\n          stagger={WORD_STAGGER}\n          delay={0.1}\n          blur={8}\n          yOffset=\"30%\"\n          className=\"text-balance text-3xl font-semibold leading-[1.1] tracking-tight text-foreground @md:text-4xl @3xl:text-5xl @5xl:text-6xl\"\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\n        {primaryAction || secondaryAction ? (\n          <motion.div\n            {...enter(after + 0.08)}\n            className={cn(\n              \"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\n        {installCommand ? (\n          <motion.div\n            {...enter(after + 0.16)}\n            className=\"inline-flex h-10 max-w-full items-center gap-2 rounded-full border border-border bg-card/80 pr-1 pl-4 font-mono text-xs text-foreground backdrop-blur @md:text-sm\"\n          >\n            <span aria-hidden className=\"select-none text-muted-foreground\">\n              $\n            </span>\n            <code className=\"min-w-0 truncate\">{installCommand}</code>\n            <CopyButton\n              value={installCommand}\n              variant=\"ghost\"\n              labels={{ idle: \"Copy install command\" }}\n              className=\"rounded-full\"\n            />\n          </motion.div>\n        ) : null}\n      </div>\n\n      {media ? (\n        <div className=\"relative px-5 [perspective:1200px]\">\n          <motion.div\n            initial={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, rotateX: 22, y: 40, scale: 0.96 }\n            }\n            whileInView={\n              reduce\n                ? { opacity: 1 }\n                : { opacity: 1, rotateX: 0, y: 0, scale: 1 }\n            }\n            viewport={{ once: true, amount: 0.2 }}\n            transition={\n              reduce\n                ? { duration: 0.3, ease: EASE_OUT }\n                : { ...MEDIA_SPRING, opacity: { duration: 0.5, ease: EASE_OUT } }\n            }\n            style={{ transformOrigin: \"50% 0%\" }}\n            className={cn(\n              \"mx-auto w-full max-w-4xl overflow-hidden rounded-2xl border border-border bg-card shadow-2xl shadow-foreground/5\",\n              mediaClassName,\n            )}\n          >\n            {media}\n          </motion.div>\n        </div>\n      ) : null}\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/copy-button.tsx","type":"registry:component","target":"@components/motion/copy-button.tsx","content":"\"use client\";\n\nimport { Check, Copy, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { type ReactNode, useCallback, useEffect, useRef, useState } from \"react\";\nimport { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CopyButtonStatus = \"idle\" | \"copied\" | \"error\";\nexport type CopyButtonVariant = \"secondary\" | \"ghost\" | \"outline\" | \"primary\";\nexport type CopyButtonSize = \"sm\" | \"md\" | \"icon\";\n\nexport interface CopyButtonLabels {\n  idle: string;\n  copied: string;\n  error: string;\n}\n\nexport interface CopyButtonProps\n  extends Omit<\n    HTMLMotionProps<\"button\">,\n    \"value\" | \"children\" | \"onCopy\" | \"onError\" | \"onClick\"\n  > {\n  /** Text to copy, or a (possibly async) getter evaluated on click. */\n  value: string | (() => string | Promise<string>);\n  /** Show a text label next to the icon that swaps with the status. */\n  showLabel?: boolean;\n  /** Label text per status. Also used for the accessible name and announcement. */\n  labels?: Partial<CopyButtonLabels>;\n  /** How long the copied/error state lingers before resetting, in ms. */\n  timeout?: number;\n  variant?: CopyButtonVariant;\n  size?: CopyButtonSize;\n  /** Override the idle / copied / error icons. */\n  icons?: Partial<Record<CopyButtonStatus, ReactNode>>;\n  onCopy?: (text: string) => void;\n  onCopyError?: (error: unknown) => void;\n  onStatusChange?: (status: CopyButtonStatus) => void;\n}\n\nconst DEFAULT_LABELS: CopyButtonLabels = {\n  idle: \"Copy\",\n  copied: \"Copied\",\n  error: \"Failed\",\n};\n\nconst VARIANT_CLASS: Record<CopyButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary:\n    \"border border-border bg-card text-muted-foreground hover:text-foreground\",\n  ghost: \"text-muted-foreground hover:bg-primary/5 hover:text-foreground\",\n  outline:\n    \"border border-border bg-transparent text-muted-foreground hover:bg-primary/5 hover:text-foreground\",\n};\n\nconst SIZE_CLASS: Record<CopyButtonSize, string> = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-xs\",\n  md: \"h-10 gap-2 rounded-full px-4 text-sm\",\n  icon: \"size-8 rounded-lg\",\n};\n\nconst SWAP_BLUR = \"blur(6px)\";\n\nconst ICON_VARIANTS: Variants = {\n  initial: { opacity: 0, scale: 0.3, filter: SWAP_BLUR },\n  animate: {\n    opacity: 1,\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    scale: 0.3,\n    filter: SWAP_BLUR,\n    transition: { duration: 0.12, ease: EASE_OUT },\n  },\n};\n\nconst LABEL_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"60%\", filter: SWAP_BLUR },\n  animate: {\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    y: \"-60%\",\n    filter: SWAP_BLUR,\n    transition: { duration: 0.12, ease: EASE_OUT },\n  },\n};\n\nconst FADE_VARIANTS: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.15 } },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\n/** `document.execCommand(\"copy\")` fallback for insecure contexts and old browsers. */\nfunction legacyCopy(text: string) {\n  const textarea = document.createElement(\"textarea\");\n  textarea.value = text;\n  textarea.setAttribute(\"readonly\", \"\");\n  textarea.style.position = \"fixed\";\n  textarea.style.top = \"0\";\n  textarea.style.left = \"0\";\n  textarea.style.opacity = \"0\";\n  textarea.style.pointerEvents = \"none\";\n  document.body.appendChild(textarea);\n  const selection = document.getSelection();\n  const previous =\n    selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;\n  textarea.select();\n  let ok = false;\n  try {\n    ok = document.execCommand(\"copy\");\n  } finally {\n    document.body.removeChild(textarea);\n    if (previous && selection) {\n      selection.removeAllRanges();\n      selection.addRange(previous);\n    }\n  }\n  if (!ok) throw new Error(\"Copy command was rejected\");\n}\n\n/** Copy text with the async Clipboard API, falling back to execCommand. */\nexport async function copyToClipboard(text: string) {\n  if (typeof navigator !== \"undefined\" && navigator.clipboard?.writeText) {\n    try {\n      await navigator.clipboard.writeText(text);\n      return;\n    } catch {\n      // Permission denied or insecure context — try the legacy path.\n    }\n  }\n  legacyCopy(text);\n}\n\nexport function CopyButton({\n  value,\n  showLabel = false,\n  labels,\n  timeout = 1600,\n  variant = \"secondary\",\n  size,\n  icons,\n  onCopy,\n  onCopyError,\n  onStatusChange,\n  className,\n  disabled,\n  type = \"button\",\n  ...props\n}: CopyButtonProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [status, setStatus] = useState<CopyButtonStatus>(\"idle\");\n  const timer = useRef<number | undefined>(undefined);\n  const text = { ...DEFAULT_LABELS, ...labels };\n  const resolvedSize = size ?? (showLabel ? \"sm\" : \"icon\");\n\n  useEffect(\n    () => () => {\n      if (timer.current) window.clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  const update = useCallback(\n    (next: CopyButtonStatus) => {\n      setStatus(next);\n      onStatusChange?.(next);\n    },\n    [onStatusChange],\n  );\n\n  const handleClick = async () => {\n    if (timer.current) window.clearTimeout(timer.current);\n    try {\n      const resolved = typeof value === \"function\" ? await value() : value;\n      await copyToClipboard(resolved);\n      update(\"copied\");\n      onCopy?.(resolved);\n    } catch (error) {\n      update(\"error\");\n      onCopyError?.(error);\n    }\n    timer.current = window.setTimeout(() => update(\"idle\"), timeout);\n  };\n\n  const iconNode: Record<CopyButtonStatus, ReactNode> = {\n    idle: icons?.idle ?? <Copy />,\n    copied: icons?.copied ?? <Check className=\"text-(--color-success)\" />,\n    error: icons?.error ?? <X className=\"text-destructive\" />,\n  };\n\n  const iconVariants = reduce ? FADE_VARIANTS : ICON_VARIANTS;\n  const labelVariants = reduce ? FADE_VARIANTS : LABEL_VARIANTS;\n\n  return (\n    <>\n    <motion.button\n      type={type}\n      disabled={disabled}\n      onClick={handleClick}\n      whileTap={reduce || disabled ? undefined : { scale: 0.9 }}\n      transition={SPRING_PRESS}\n      aria-label={showLabel ? undefined : text[status]}\n      data-status={status}\n      className={cn(\n        \"relative inline-flex shrink-0 select-none items-center justify-center font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n        TOUCH_TARGET_CLASS,\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[resolvedSize],\n        className,\n      )}\n      {...props}\n    >\n      <span aria-hidden=\"true\" className=\"relative grid size-3.5 place-items-center [&_svg]:size-3.5\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={status}\n            variants={iconVariants}\n            initial=\"initial\"\n            animate=\"animate\"\n            exit=\"exit\"\n            className=\"col-start-1 row-start-1 grid place-items-center\"\n          >\n            {iconNode[status]}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      {showLabel ? (\n        <span className=\"relative grid overflow-hidden\">\n          {/* Every label sits invisibly in the same cell so the button keeps\n              the width of the longest one and never reflows mid-swap. */}\n          {(Object.keys(text) as CopyButtonStatus[]).map((key) => (\n            <span\n              key={key}\n              aria-hidden=\"true\"\n              className=\"invisible col-start-1 row-start-1 whitespace-nowrap\"\n            >\n              {text[key]}\n            </span>\n          ))}\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={status}\n              variants={labelVariants}\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"col-start-1 row-start-1 whitespace-nowrap text-left\"\n            >\n              {text[status]}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ) : null}\n    </motion.button>\n    {/* Outside the button so the announcement never joins its accessible name. */}\n    <span aria-live=\"polite\" className=\"sr-only\">\n      {status === \"copied\"\n        ? `${text.copied} to clipboard`\n        : status === \"error\"\n          ? `${text.error} to copy`\n          : \"\"}\n    </span>\n    </>\n  );\n}\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/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"}]}