{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"blog-list","type":"registry:block","title":"Blog List","description":"Blog index with category chips and a gliding pill, a wide featured post, and cards with gradient or image covers, excerpt, author avatar, read time and an en-US date that lift on hover; filtering reflows the grid with shared layout and Load more appends the next page with a stagger, falling back to `onLoadMore` for server pagination.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/blog-list.tsx","type":"registry:component","target":"@components/motion/blog-list.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/blog-list\n\nimport { ArrowUpRight, Clock } from \"lucide-react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useId, useMemo, useState } from \"react\";\nimport { Button } from \"@/components/motion/button\";\nimport { Loader } from \"@/components/motion/loader\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface BlogPost {\n  id: string;\n  title: string;\n  excerpt: string;\n  category: string;\n  author: { name: string; avatar?: string };\n  /** ISO date; shown in `locale`, UTC, so server and client agree. */\n  date: string;\n  /** Minutes. */\n  readTime: number;\n  /** Cover image URL. Without one a gradient from the theme's chart tokens is drawn. */\n  cover?: string;\n  href?: string;\n  /** The first featured post in the current filter gets the wide card. */\n  featured?: boolean;\n}\n\nexport interface BlogListProps {\n  posts: BlogPost[];\n  /** Chip order. Default: categories in order of first appearance. */\n  categories?: string[];\n  /** Controlled category; `\"all\"` shows everything. */\n  category?: string;\n  defaultCategory?: string;\n  onCategoryChange?: (category: string) => void;\n  /** Posts revealed per \"Load more\". Default 6. */\n  pageSize?: number;\n  /**\n   * Fetch more posts when the local ones run out. Append them to `posts`;\n   * the button shows a spinner until the promise settles.\n   */\n  onLoadMore?: () => Promise<void> | void;\n  /** Whether `onLoadMore` can return more. Default false. */\n  hasMore?: boolean;\n  /** Called for posts without `href`. */\n  onPostClick?: (post: BlogPost) => void;\n  /** Label of the everything chip. Default \"All\". */\n  allLabel?: string;\n  /** Default \"en-US\". */\n  locale?: string;\n  className?: string;\n}\n\n/** Deterministic token pair for a cover, from the category name. */\nfunction coverStyle(seed: string) {\n  let hash = 0;\n  for (const ch of seed) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;\n  const a = (hash % 5) + 1;\n  // Unsigned shift: `>>` goes negative once the hash passes 2^31.\n  const second = ((hash >>> 3) % 5) + 1;\n  const b = second === a ? (a % 5) + 1 : second;\n  return {\n    backgroundImage: `radial-gradient(circle at 80% 15%, color-mix(in oklab, var(--chart-${b}) 80%, transparent), transparent 55%), linear-gradient(135deg, var(--chart-${a}), color-mix(in oklab, var(--chart-${a}) 40%, var(--card)))`,\n  };\n}\n\n/**\n * Blog index block: category chips with a gliding pill, a wide featured post,\n * and a card grid (gradient or image cover, title, excerpt, author, read time,\n * en-US date) where cards lift on hover. Filtering reflows the grid with\n * shared layout; \"Load more\" appends the next page with a stagger, then falls\n * back to `onLoadMore` for server pagination.\n */\nexport function BlogList({\n  posts,\n  categories: categoriesProp,\n  category: categoryProp,\n  defaultCategory = \"all\",\n  onCategoryChange,\n  pageSize = 6,\n  onLoadMore,\n  hasMore = false,\n  onPostClick,\n  allLabel = \"All\",\n  locale = \"en-US\",\n  className,\n}: BlogListProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const [internal, setInternal] = useState(defaultCategory);\n  const category = categoryProp ?? internal;\n  const [count, setCount] = useState(pageSize);\n  // Index from which cards were just appended; they stagger in from there.\n  const [appendFrom, setAppendFrom] = useState(Number.POSITIVE_INFINITY);\n  const [loading, setLoading] = useState(false);\n\n  const setCategory = useCallback(\n    (next: string) => {\n      if (categoryProp === undefined) setInternal(next);\n      onCategoryChange?.(next);\n      setCount(pageSize);\n      setAppendFrom(Number.POSITIVE_INFINITY);\n    },\n    [categoryProp, onCategoryChange, pageSize],\n  );\n\n  const categories = useMemo(\n    () => categoriesProp ?? Array.from(new Set(posts.map((p) => p.category))),\n    [categoriesProp, posts],\n  );\n\n  const dateFormat = useMemo(\n    () =>\n      new Intl.DateTimeFormat(locale, {\n        month: \"short\",\n        day: \"numeric\",\n        year: \"numeric\",\n        timeZone: \"UTC\",\n      }),\n    [locale],\n  );\n\n  const filtered = category === \"all\" ? posts : posts.filter((p) => p.category === category);\n  const featured = filtered.find((p) => p.featured);\n  const rest = filtered.filter((p) => p !== featured);\n  const shown = rest.slice(0, count);\n  const canReveal = count < rest.length;\n  const canFetch = !canReveal && Boolean(onLoadMore) && hasMore;\n\n  const loadMore = async () => {\n    setAppendFrom(shown.length);\n    if (canReveal) {\n      setCount(count + pageSize);\n      return;\n    }\n    if (!onLoadMore) return;\n    setLoading(true);\n    try {\n      await onLoadMore();\n      setCount(count + pageSize);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  // Entrance/exit/reflow for one grid slot; `delay` staggers appended cards.\n  const appear = (delay: number) =>\n    ({\n      layout: reduce ? false : \"position\",\n      initial: { opacity: 0, y: reduce ? 0 : 14 },\n      animate: { opacity: 1, y: 0, transition: { duration: 0.32, ease: EASE_OUT, delay } },\n      exit: { opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.16 } },\n      transition: SPRING_LAYOUT,\n    }) as const;\n\n  const chips = [{ id: \"all\", label: allLabel },...categories.map((c) => ({ id: c, label: c }))];\n\n  return (\n    <div className={cn(\"@container flex w-full flex-col gap-4\", className)}>\n      <LayoutGroup id={`${baseId}-chips`}>\n        <fieldset className=\"flex flex-wrap gap-1.5\">\n          <legend className=\"sr-only\">Filter by category</legend>\n          {chips.map((chip) => {\n            const active = chip.id === category;\n            return (\n              <button\n                key={chip.id}\n                type=\"button\"\n                aria-pressed={active}\n                onClick={() => setCategory(chip.id)}\n                className={cn(\n                  \"relative h-7 rounded-full px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-primary\",\n                  active ? \"text-primary-foreground\" : \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n                )}\n              >\n                {active ? (\n                  <motion.span\n                    layoutId=\"chip-pill\"\n                    transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                    className=\"absolute inset-0 rounded-full bg-primary\"\n                  />\n                ) : null}\n                <span className=\"relative\">{chip.label}</span>\n              </button>\n            );\n          })}\n        </fieldset>\n      </LayoutGroup>\n\n      <LayoutGroup id={`${baseId}-grid`}>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {featured ? (\n            <motion.div key={`featured-${featured.id}`} {...appear(0)} className=\"mb-3\">\n              <PostCard\n                post={featured}\n                featured\n                date={dateFormat.format(new Date(featured.date))}\n                onPostClick={onPostClick}\n              />\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n\n        <ul className=\"grid grid-cols-1 gap-3 @md:grid-cols-2 @2xl:grid-cols-3\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {shown.map((post, i) => (\n              <motion.li\n                key={post.id}\n                {...appear(i >= appendFrom ? (i - appendFrom) * 0.06 : 0)}\n                className=\"flex\"\n              >\n                <PostCard\n                  post={post}\n                  date={dateFormat.format(new Date(post.date))}\n                  onPostClick={onPostClick}\n                />\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n      </LayoutGroup>\n\n      {filtered.length === 0 ? (\n        <p className=\"py-8 text-center text-sm text-muted-foreground\">No posts in this category yet.</p>\n      ) : null}\n\n      {canReveal || canFetch ? (\n        <div className=\"flex justify-center\">\n          <Button variant=\"secondary\" size=\"sm\" onClick={loadMore} disabled={loading} aria-busy={loading}>\n            {loading ? <Loader size={14} label=\"Loading posts\" /> : null}\n            {loading ? \"Loading…\" : \"Load more\"}\n          </Button>\n        </div>\n      ) : null}\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {`Showing ${shown.length + (featured ? 1 : 0)} of ${filtered.length} posts`}\n      </p>\n    </div>\n  );\n}\n\nfunction PostCard({\n  post,\n  featured = false,\n  date,\n  onPostClick,\n}: {\n  post: BlogPost;\n  featured?: boolean;\n  date: string;\n  onPostClick?: (post: BlogPost) => void;\n}) {\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  const lift = canHover && !reduce;\n  const titleId = useId();\n\n  const initials = post.author.name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((p) => p[0]?.toUpperCase())\n    .join(\"\");\n\n  // Stretched target: the title is the one link/button, its ::after covers the card.\n  const stretched =\n    \"outline-none after:absolute after:inset-0 after:rounded-2xl after:content-[''] focus-visible:after:ring-2 focus-visible:after:ring-primary\";\n\n  return (\n    <motion.article\n      aria-labelledby={titleId}\n      whileHover={lift ? { y: -4 } : undefined}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"group relative flex overflow-hidden rounded-2xl border border-border bg-card text-card-foreground transition-shadow hover:shadow-md\",\n        featured ? \"flex-col @md:flex-row\" : \"w-full flex-col\",\n      )}\n    >\n      <div\n        className={cn(\n          \"relative shrink-0 overflow-hidden\",\n          featured ? \"h-36 @md:h-auto @md:w-[45%]\" : \"h-24\",\n        )}\n        style={post.cover ? undefined : coverStyle(post.category)}\n      >\n        {post.cover ? (\n          // biome-ignore lint/performance/noImgElement: library component, framework-agnostic.\n          <img\n            src={post.cover}\n            alt=\"\"\n            className=\"size-full object-cover transition-transform duration-500 group-hover:scale-105\"\n          />\n        ) : null}\n        <span className=\"absolute top-2.5 left-2.5 rounded-full bg-background/85 px-2 py-0.5 text-[10px] font-medium text-foreground backdrop-blur\">\n          {post.category}\n        </span>\n        {featured ? (\n          <span className=\"absolute top-2.5 right-2.5 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground\">\n            Featured\n          </span>\n        ) : null}\n      </div>\n\n      <div className={cn(\"flex min-w-0 flex-1 flex-col\", featured ? \"p-4 @md:p-5\" : \"p-3.5\")}>\n        <h3\n          id={titleId}\n          className={cn(\"font-semibold leading-snug tracking-tight\", featured ? \"text-lg\" : \"text-sm\")}\n        >\n          {post.href ? (\n            <a href={post.href} className={stretched}>\n              {post.title}\n            </a>\n          ) : (\n            <button type=\"button\" onClick={() => onPostClick?.(post)} className={cn(\"text-left\", stretched)}>\n              {post.title}\n            </button>\n          )}\n        </h3>\n        <p\n          className={cn(\n            \"mt-1.5 text-muted-foreground\",\n            featured ? \"line-clamp-3 text-sm\" : \"line-clamp-2 text-xs\",\n          )}\n        >\n          {post.excerpt}\n        </p>\n        <div className=\"mt-auto flex items-center gap-2 pt-3 text-[11px] text-muted-foreground\">\n          {post.author.avatar ? (\n            // biome-ignore lint/performance/noImgElement: library component, framework-agnostic.\n            <img src={post.author.avatar} alt=\"\" className=\"size-5 rounded-full object-cover\" />\n          ) : (\n            <span\n              aria-hidden\n              className=\"grid size-5 place-items-center rounded-full bg-primary/15 text-[9px] font-semibold text-foreground\"\n            >\n              {initials}\n            </span>\n          )}\n          <span className=\"min-w-0 truncate font-medium text-foreground/80\">{post.author.name}</span>\n          <span aria-hidden>·</span>\n          <time dateTime={post.date} className=\"shrink-0\">\n            {date}\n          </time>\n          <span className=\"ml-auto inline-flex shrink-0 items-center gap-1\">\n            <Clock aria-hidden className=\"size-3\" />\n            {post.readTime} min\n          </span>\n          {featured ? (\n            <ArrowUpRight\n              aria-hidden\n              className=\"size-4 shrink-0 text-foreground transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5\"\n            />\n          ) : null}\n        </div>\n      </div>\n    </motion.article>\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/loader.tsx","type":"registry:component","target":"@components/motion/loader.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useState } from \"react\";\nimport { EASE_IN_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type LoaderVariant =\n  | \"spinner\"\n  | \"dots\"\n  | \"bars\"\n  | \"dot-matrix\"\n  | \"dither\"\n  | \"ascii\"\n  | \"ascii-line\"\n  | \"ascii-braille\"\n  | \"ascii-blocks\"\n  | \"ascii-bounce\"\n  | \"morph\"\n  | \"comet\"\n  | \"scramble\"\n  | \"metaballs\"\n  | \"newton\"\n  | \"helix\"\n  | \"percent\";\n\n// Terminal-style frame sets — the loaders CLI AI agents cycle through.\nconst ASCII_SETS: Record<string, string[]> = {\n  ascii: [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"],\n  \"ascii-line\": [\"|\", \"/\", \"-\", \"\\\\\"],\n  \"ascii-braille\": [\"⣾\", \"⣽\", \"⣻\", \"⢿\", \"⡿\", \"⣟\", \"⣯\", \"⣷\"],\n  \"ascii-blocks\": [\"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\", \"▇\", \"▆\", \"▅\", \"▄\", \"▃\", \"▂\"],\n  \"ascii-bounce\": [\"⠁\", \"⠂\", \"⠄\", \"⡀\", \"⢀\", \"⠠\", \"⠐\", \"⠈\"],\n};\n\nexport interface LoaderProps {\n  /** Which animation to render. */\n  variant?: LoaderVariant;\n  /** Base square size in px. Everything scales from this. */\n  size?: number;\n  /** Seconds per animation cycle. */\n  speed?: number;\n  /** Accessible label announced to screen readers. */\n  label?: string;\n  className?: string;\n}\n\n// Reduced motion keeps a calm opacity pulse and drops every transform.\nconst REDUCED = {\n  animate: { opacity: [1, 0.4, 1] },\n  transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\nexport function Loader({\n  variant = \"spinner\",\n  size = 32,\n  speed = 1,\n  label = \"Loading\",\n  className,\n}: LoaderProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <span\n      role=\"status\"\n      aria-label={label}\n      className={cn(\n        \"inline-flex items-center justify-center text-foreground\",\n        className,\n      )}\n    >\n      {variant === \"spinner\" && <Spinner size={size} speed={speed} reduce={reduce} />}\n      {variant === \"dots\" && <Dots size={size} speed={speed} reduce={reduce} />}\n      {variant === \"bars\" && <Bars size={size} speed={speed} reduce={reduce} />}\n      {variant === \"dot-matrix\" && (\n        <DotMatrix size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"dither\" && <Dither size={size} speed={speed} reduce={reduce} />}\n      {ASCII_SETS[variant] && (\n        <Ascii frames={ASCII_SETS[variant]} size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"morph\" && <Morph size={size} speed={speed} reduce={reduce} />}\n      {variant === \"comet\" && <Comet size={size} speed={speed} reduce={reduce} />}\n      {variant === \"scramble\" && (\n        <Scramble size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"metaballs\" && (\n        <Metaballs size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"newton\" && <Newton size={size} speed={speed} reduce={reduce} />}\n      {variant === \"helix\" && <Helix size={size} speed={speed} reduce={reduce} />}\n      {variant === \"percent\" && (\n        <Percent size={size} speed={speed} reduce={reduce} />\n      )}\n      <span className=\"sr-only\">{label}</span>\n    </span>\n  );\n}\n\ninterface PartProps {\n  size: number;\n  speed: number;\n  reduce: boolean;\n}\n\nfunction Spinner({ size, speed, reduce }: PartProps) {\n  const stroke = Math.max(2, size * 0.09);\n  const r = (size - stroke) / 2;\n  return (\n    <motion.svg\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      animate={reduce ? REDUCED.animate : { rotate: 360 }}\n      transition={\n        reduce\n          ? REDUCED.transition\n          : { duration: speed, ease: \"linear\", repeat: Infinity }\n      }\n    >\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={r}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeOpacity={0.2}\n        strokeWidth={stroke}\n      />\n      <path\n        d={`M ${size / 2} ${size / 2 - r} A ${r} ${r} 0 0 1 ${size / 2 + r} ${size / 2}`}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={stroke}\n        strokeLinecap=\"round\"\n      />\n    </motion.svg>\n  );\n}\n\nfunction Dots({ size, speed, reduce }: PartProps) {\n  const dot = size * 0.24;\n  return (\n    <span className=\"flex items-center\" style={{ gap: size * 0.14 }}>\n      {[0, 1, 2].map((i) => (\n        <motion.span\n          key={i}\n          className=\"rounded-full bg-current\"\n          style={{ width: dot, height: dot }}\n          animate={\n            reduce\n              ? { opacity: [0.4, 1, 0.4] }\n              : { y: [0, -size * 0.3, 0], opacity: [0.5, 1, 0.5] }\n          }\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: i * speed * 0.16,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction Ascii({\n  frames,\n  size,\n  speed,\n  reduce,\n}: PartProps & { frames: string[] }) {\n  const [frame, setFrame] = useState(0);\n  useEffect(() => {\n    // Reduced motion slows the cycle rather than stopping it — it's a glyph\n    // swap, not on-screen movement.\n    const step = ((reduce ? speed * 2.5 : speed) / frames.length) * 1000;\n    const id = setInterval(\n      () => setFrame((f) => (f + 1) % frames.length),\n      step,\n    );\n    return () => clearInterval(id);\n  }, [frames.length, speed, reduce]);\n\n  return (\n    <span\n      className=\"font-mono leading-none tabular-nums\"\n      style={{ fontSize: size, lineHeight: 1 }}\n    >\n      {frames[frame % frames.length]}\n    </span>\n  );\n}\n\n// Each shape is sampled at the same number of points and emitted as an SVG\n// path with identical command structure, so framer tweens the `d` attribute\n// point-to-point — a real morph, not a snap. (clip-path polygon strings don't\n// interpolate reliably in framer, which left the shapes broken.)\nconst MORPH_POINTS = 24;\n\nfunction ngonRadius(ang: number, n: number, phase = 0) {\n  const seg = (2 * Math.PI) / n;\n  const a = ang - phase;\n  const local = (((a % seg) + seg) % seg) - seg / 2;\n  return Math.cos(Math.PI / n) / Math.cos(local);\n}\n\nfunction morphPath(radiusAt: (ang: number) => number) {\n  const parts: string[] = [];\n  for (let i = 0; i < MORPH_POINTS; i++) {\n    const ang = (i / MORPH_POINTS) * 2 * Math.PI - Math.PI / 2;\n    const r = Math.min(1.05, radiusAt(ang));\n    const x = (50 + Math.cos(ang) * 46 * r).toFixed(2);\n    const y = (50 + Math.sin(ang) * 46 * r).toFixed(2);\n    parts.push(`${i === 0 ? \"M\" : \"L\"}${x} ${y}`);\n  }\n  return `${parts.join(\" \")} Z`;\n}\n\nconst MORPH_PATHS = [\n  morphPath(() => 1), // circle\n  morphPath((a) => ngonRadius(a, 4, Math.PI / 4)), // square\n  morphPath((a) => ngonRadius(a, 3)), // triangle\n  morphPath((a) => ngonRadius(a, 6)), // hexagon\n  morphPath((a) => ngonRadius(a, 4)), // diamond\n];\n\n// Each shape appears twice in a row so it fully forms and HOLDS before the\n// next morph. Even keyframe spacing then alternates hold / morph segments.\nconst MORPH_SEQ = [...MORPH_PATHS.flatMap((p) => [p, p]), MORPH_PATHS[0]];\n// Rotation and scale only change across the morph segments, staying put on the\n// holds, so a settled shape sits still.\nconst MORPH_ROT = [0, 0, 72, 72, 144, 144, 216, 216, 288, 288, 360];\nconst MORPH_SCALE = [1, 1, 0.88, 0.88, 1, 1, 0.88, 0.88, 1, 1, 1];\n\nfunction Morph({ size, speed, reduce }: PartProps) {\n  return (\n    <motion.svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 100 100\"\n      role=\"img\"\n      animate={\n        reduce\n          ? { opacity: [1, 0.4, 1] }\n          : { rotate: MORPH_ROT, scale: MORPH_SCALE }\n      }\n      transition={\n        reduce\n          ? { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }\n          : { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }\n      }\n    >\n      <title>Loading</title>\n      <motion.path\n        fill=\"currentColor\"\n        d={MORPH_PATHS[0]}\n        animate={reduce ? undefined : { d: MORPH_SEQ }}\n        transition={\n          reduce\n            ? undefined\n            : { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }\n        }\n      />\n    </motion.svg>\n  );\n}\n\nconst COMET_TRAIL = [0, 1, 2, 3, 4, 5];\n\nfunction Comet({ size, speed, reduce }: PartProps) {\n  const head = size * 0.2;\n  const r = size / 2 - head / 2;\n  return (\n    <span className=\"relative\" style={{ width: size, height: size }}>\n      <motion.span\n        className=\"absolute inset-0\"\n        animate={reduce ? REDUCED.animate : { rotate: 360 }}\n        transition={\n          reduce\n            ? REDUCED.transition\n            : { duration: speed, ease: \"linear\", repeat: Infinity }\n        }\n      >\n        {COMET_TRAIL.map((i) => {\n          const scale = 1 - i * 0.13;\n          const sz = head * scale;\n          return (\n            <span\n              key={i}\n              className=\"absolute top-1/2 left-1/2 rounded-full bg-current\"\n              style={{\n                width: sz,\n                height: sz,\n                marginLeft: -sz / 2,\n                marginTop: -sz / 2,\n                opacity: 1 - i * 0.16,\n                transform: `rotate(${-i * 15}deg) translateY(${-r}px)`,\n              }}\n            />\n          );\n        })}\n      </motion.span>\n    </span>\n  );\n}\n\nconst SCRAMBLE_TARGET = \"LOADING\";\nconst SCRAMBLE_GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/*#@\";\n\nfunction Scramble({ size, speed, reduce }: PartProps) {\n  const [text, setText] = useState(SCRAMBLE_TARGET);\n  useEffect(() => {\n    if (reduce) {\n      setText(SCRAMBLE_TARGET);\n      return;\n    }\n    let tick = 0;\n    const total = SCRAMBLE_TARGET.length + 4;\n    const id = setInterval(\n      () => {\n        const reveal = tick % total;\n        let s = \"\";\n        for (let i = 0; i < SCRAMBLE_TARGET.length; i++) {\n          s +=\n            i < reveal\n              ? SCRAMBLE_TARGET[i]\n              : SCRAMBLE_GLYPHS[\n                  Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)\n                ];\n        }\n        setText(s);\n        tick++;\n      },\n      (speed / SCRAMBLE_TARGET.length) * 1000 * 0.55,\n    );\n    return () => clearInterval(id);\n  }, [speed, reduce]);\n\n  return (\n    <span\n      className=\"font-mono font-medium tracking-[0.2em] tabular-nums\"\n      style={{ fontSize: size * 0.42 }}\n    >\n      {text}\n    </span>\n  );\n}\n\nfunction Metaballs({ size, speed, reduce }: PartProps) {\n  const id = useId().replace(/:/g, \"\");\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 100 100\" role=\"img\">\n      <title>Loading</title>\n      <defs>\n        <filter id={id}>\n          <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"5\" result=\"b\" />\n          <feColorMatrix\n            in=\"b\"\n            values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -8\"\n          />\n        </filter>\n      </defs>\n      <g filter={`url(#${id})`} fill=\"currentColor\">\n        <motion.circle\n          cy=\"50\"\n          r=\"15\"\n          animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [30, 70, 30] }}\n          transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}\n          cx={reduce ? 40 : 30}\n        />\n        <motion.circle\n          cy=\"50\"\n          r=\"15\"\n          animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [70, 30, 70] }}\n          transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}\n          cx={reduce ? 60 : 70}\n        />\n      </g>\n    </svg>\n  );\n}\n\nconst NEWTON_BALLS = [0, 1, 2, 3, 4];\n\nfunction Newton({ size, speed, reduce }: PartProps) {\n  const d = size * 0.2;\n  const out = d * 1.1;\n  // Only the end balls move: the left slides out and back on the first half,\n  // then the right on the second half — the impact appears to jump the three\n  // still middle balls. Pure horizontal slide, no swing, no strings.\n  const moves: Record<number, { x: number[]; times: number[] }> = {\n    0: { x: [0, -out, 0, 0], times: [0, 0.28, 0.5, 1] },\n    4: { x: [0, 0, out, 0], times: [0, 0.5, 0.78, 1] },\n  };\n\n  return (\n    <span className=\"flex items-center justify-center\" style={{ height: d }}>\n      {NEWTON_BALLS.map((i) => {\n        const move = moves[i];\n        return (\n          <motion.span\n            key={i}\n            className=\"rounded-full bg-current\"\n            style={{ width: d, height: d }}\n            animate={reduce || !move ? undefined : { x: move.x }}\n            transition={\n              reduce || !move\n                ? undefined\n                : {\n                    duration: speed * 1.5,\n                    ease: EASE_IN_OUT,\n                    repeat: Infinity,\n                    times: move.times,\n                  }\n            }\n          />\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Helix({ size, speed, reduce }: PartProps) {\n  const rows = 7;\n  const dot = size * 0.14;\n  const amp = size * 0.32;\n  return (\n    <span className=\"relative\" style={{ width: size, height: size }}>\n      {Array.from({ length: rows }, (_, r) => {\n        const top = (r / (rows - 1)) * (size - dot);\n        const delay = (r / rows) * speed;\n        return (\n          <span key={`row-${top}`}>\n            <motion.span\n              className=\"absolute rounded-full bg-current\"\n              style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}\n              animate={\n                reduce\n                  ? { opacity: [0.4, 1, 0.4] }\n                  : {\n                      x: [amp, -amp, amp],\n                      scale: [1, 0.5, 1],\n                      opacity: [1, 0.45, 1],\n                    }\n              }\n              transition={{\n                duration: speed,\n                ease: EASE_IN_OUT,\n                repeat: Infinity,\n                delay,\n              }}\n            />\n            <motion.span\n              className=\"absolute rounded-full bg-current\"\n              style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}\n              animate={\n                reduce\n                  ? { opacity: [0.4, 1, 0.4] }\n                  : {\n                      x: [-amp, amp, -amp],\n                      scale: [0.5, 1, 0.5],\n                      opacity: [0.45, 1, 0.45],\n                    }\n              }\n              transition={{\n                duration: speed,\n                ease: EASE_IN_OUT,\n                repeat: Infinity,\n                delay,\n              }}\n            />\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Percent({ size, speed, reduce }: PartProps) {\n  const [p, setP] = useState(0);\n  useEffect(() => {\n    const dur = (reduce ? speed * 2 : speed) * 1000;\n    const start = { t: 0 };\n    const tickMs = 40;\n    const id = setInterval(() => {\n      start.t += tickMs;\n      const next = Math.min(100, Math.round((start.t / dur) * 100));\n      setP(next);\n      if (next >= 100) start.t = 0;\n    }, tickMs);\n    return () => clearInterval(id);\n  }, [speed, reduce]);\n\n  return (\n    <span\n      className=\"flex flex-col items-center\"\n      style={{ gap: size * 0.14, width: size * 1.4 }}\n    >\n      <span\n        className=\"font-mono font-medium tabular-nums\"\n        style={{ fontSize: size * 0.42, lineHeight: 1 }}\n      >\n        {p}%\n      </span>\n      <span\n        className=\"w-full overflow-hidden rounded-full bg-current/15\"\n        style={{ height: Math.max(3, size * 0.1) }}\n      >\n        <span\n          className=\"block h-full rounded-full bg-current\"\n          style={{ width: `${p}%` }}\n        />\n      </span>\n    </span>\n  );\n}\n\nfunction Bars({ size, speed, reduce }: PartProps) {\n  const bar = size * 0.16;\n  return (\n    <span className=\"flex items-center\" style={{ gap: size * 0.1, height: size }}>\n      {[0, 1, 2, 3].map((i) => (\n        <motion.span\n          key={i}\n          className=\"rounded-full bg-current\"\n          style={{ width: bar, height: size, originY: 1 }}\n          animate={\n            reduce ? { opacity: [0.4, 1, 0.4] } : { scaleY: [0.3, 1, 0.3] }\n          }\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: i * speed * 0.12,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction DotMatrix({ size, speed, reduce }: PartProps) {\n  const n = 3;\n  const gap = size * 0.14;\n  const dot = (size - gap * (n - 1)) / n;\n  const cells = Array.from({ length: n * n }, (_, idx) => idx);\n  return (\n    <span\n      className=\"grid\"\n      style={{\n        gap,\n        gridTemplateColumns: `repeat(${n}, ${dot}px)`,\n      }}\n    >\n      {cells.map((idx) => {\n        const x = idx % n;\n        const y = Math.floor(idx / n);\n        // Diagonal wave: cells light in order of their distance from the corner.\n        const delay = ((x + y) / (2 * (n - 1))) * speed;\n        return (\n          <motion.span\n            key={idx}\n            className=\"rounded-full bg-current\"\n            style={{ width: dot, height: dot }}\n            animate={\n              reduce\n                ? { opacity: [0.3, 1, 0.3] }\n                : { opacity: [0.2, 1, 0.2], scale: [0.7, 1, 0.7] }\n            }\n            transition={{\n              duration: speed,\n              ease: EASE_IN_OUT,\n              repeat: Infinity,\n              delay,\n            }}\n          />\n        );\n      })}\n    </span>\n  );\n}\n\n// Ordered Bayer 4x4 matrix — the classic dithering threshold pattern. Cells\n// light in this order, so the fill shimmers like a dissolving halftone.\nconst BAYER_4 = [\n  0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,\n];\n\nfunction Dither({ size, speed, reduce }: PartProps) {\n  const n = 4;\n  const gap = Math.max(1, size * 0.05);\n  const cell = (size - gap * (n - 1)) / n;\n  return (\n    <span\n      className=\"grid\"\n      style={{ gap, gridTemplateColumns: `repeat(${n}, ${cell}px)` }}\n    >\n      {BAYER_4.map((order, idx) => (\n        <motion.span\n          // biome-ignore lint/suspicious/noArrayIndexKey: fixed matrix cells, order never changes\n          key={idx}\n          className=\"bg-current\"\n          style={{ width: cell, height: cell }}\n          animate={reduce ? { opacity: [0.3, 1, 0.3] } : { opacity: [0.1, 1, 0.1] }}\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: (order / BAYER_4.length) * speed,\n          }}\n        />\n      ))}\n    </span>\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/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/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":"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"}]}