{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"comparison-table","type":"registry:block","title":"Comparison Table","description":"Plan comparison grid with a monthly/yearly switch that rolls every price, plan columns in a sticky header over scrolling feature rows, collapsible feature groups, check / dash / partial / text cells, a recommended column tinted top to bottom, and a plan switcher with one cross-fading column in narrow containers — the detailed companion to PricingTable.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/comparison-table.tsx","type":"registry:component","target":"@components/motion/comparison-table.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/comparison-table\n\nimport { Check, ChevronDown, Minus } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type CSSProperties, type ReactNode, useCallback, useMemo, useState } from \"react\";\nimport { Button } from \"@/components/motion/button\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n  useCollapsible,\n} from \"@/components/motion/collapsible\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { SegmentedControl } from \"@/components/motion/segmented-control\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ComparisonBilling = \"monthly\" | \"yearly\";\n\n/** true = included, false = not included, \"partial\" = limited, anything else renders as text. */\nexport type ComparisonValue = boolean | \"partial\" | ReactNode;\n\nexport interface ComparisonPlan {\n  id: string;\n  name: string;\n  description?: string;\n  /** Price per month for each billing period (yearly = monthly equivalent). */\n  price: { monthly: number; yearly: number };\n  cta?: string;\n  /** Highlights the whole column. */\n  recommended?: boolean;\n}\n\nexport interface ComparisonFeature {\n  id: string;\n  label: ReactNode;\n  /** Secondary line under the label. */\n  hint?: ReactNode;\n  /** Value per plan id. Missing ids read as not included. */\n  values: Record<string, ComparisonValue>;\n}\n\nexport interface ComparisonGroup {\n  id: string;\n  label: string;\n  features: ComparisonFeature[];\n}\n\nexport interface ComparisonTableProps {\n  plans: ComparisonPlan[];\n  groups: ComparisonGroup[];\n  billing?: ComparisonBilling;\n  defaultBilling?: ComparisonBilling;\n  onBillingChange?: (billing: ComparisonBilling) => void;\n  /** Groups open initially. Default: all. */\n  defaultOpenGroups?: string[];\n  /** Plan shown in the narrow (single-column) layout. */\n  mobilePlan?: string;\n  defaultMobilePlan?: string;\n  onMobilePlanChange?: (planId: string) => void;\n  onSelect?: (plan: ComparisonPlan, billing: ComparisonBilling) => void;\n  /** ISO 4217 code. Default \"USD\". */\n  currency?: string;\n  /** Default \"en-US\" so server and client agree. */\n  locale?: string;\n  /** Badge beside \"Yearly\". Default \"−20%\"; null hides it. */\n  yearlyBadge?: ReactNode;\n  /** Label on the recommended column. Default \"Recommended\". */\n  recommendedLabel?: ReactNode;\n  /** Classes for the scrolling body (set a max height here). Default `max-h-[22rem]`. */\n  bodyClassName?: string;\n  className?: string;\n}\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\n/**\n * Plan comparison grid: a monthly/yearly switch rolls every price, plan\n * columns sit in a sticky header while feature rows scroll beneath, feature\n * groups collapse with a height morph, cells read check / dash / partial or\n * text, and the recommended plan's column is tinted top to bottom. In a\n * narrow container the columns give way to a plan switcher with one value\n * column that cross-fades between plans. Complements `PricingTable` (cards)\n * as the detailed \"compare all features\" section.\n */\nexport function ComparisonTable({\n  plans,\n  groups,\n  billing: billingProp,\n  defaultBilling = \"monthly\",\n  onBillingChange,\n  defaultOpenGroups,\n  mobilePlan: mobilePlanProp,\n  defaultMobilePlan,\n  onMobilePlanChange,\n  onSelect,\n  currency = \"USD\",\n  locale = \"en-US\",\n  yearlyBadge = \"−20%\",\n  recommendedLabel = \"Recommended\",\n  bodyClassName,\n  className,\n}: ComparisonTableProps) {\n  const [billing, setBilling] = useControllable(billingProp, defaultBilling, onBillingChange);\n  const [mobilePlan, setMobilePlan] = useControllable(\n    mobilePlanProp,\n    defaultMobilePlan ?? plans.find((p) => p.recommended)?.id ?? plans[0]?.id ?? \"\",\n    onMobilePlanChange,\n  );\n  const openSet = useMemo(\n    () => new Set(defaultOpenGroups ?? groups.map((g) => g.id)),\n    [defaultOpenGroups, groups],\n  );\n\n  const fractional = plans.some(\n    (p) => !Number.isInteger(p.price.monthly) || !Number.isInteger(p.price.yearly),\n  );\n  const money = useMemo(\n    () =>\n      new Intl.NumberFormat(locale, {\n        style: \"currency\",\n        currency,\n        minimumFractionDigits: fractional ? 2 : 0,\n        maximumFractionDigits: fractional ? 2 : 0,\n      }),\n    [locale, currency, fractional],\n  );\n\n  const columns: CSSProperties = {\n    gridTemplateColumns: `minmax(8.5rem, 1.35fr) repeat(${plans.length}, minmax(0, 1fr))`,\n  };\n  const current = plans.find((p) => p.id === mobilePlan) ?? plans[0];\n\n  return (\n    <div className={cn(\"@container flex w-full flex-col gap-3\", className)}>\n      <div className=\"flex justify-center\">\n        <SegmentedControl\n          aria-label=\"Billing period\"\n          size=\"sm\"\n          value={billing}\n          onValueChange={(v) => setBilling(v as ComparisonBilling)}\n          options={[\n            { value: \"monthly\", label: \"Monthly\" },\n            {\n              value: \"yearly\",\n              label: (\n                <span className=\"inline-flex items-center gap-1.5\">\n                  Yearly\n                  {yearlyBadge ? (\n                    <span className=\"rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none\">\n                      {yearlyBadge}\n                    </span>\n                  ) : null}\n                </span>\n              ),\n            },\n          ]}\n        />\n      </div>\n\n      <div className=\"overflow-hidden rounded-2xl border border-border bg-card text-card-foreground\">\n        {/* Wide layout: all plan columns */}\n        <div\n          className={cn(\n            \"hidden overflow-y-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin] @xl:block\",\n            bodyClassName ?? \"max-h-[22rem]\",\n          )}\n        >\n          <div className=\"sticky top-0 z-10 grid border-b border-border bg-card\" style={columns}>\n            <div className=\"flex items-end p-3 text-xs text-muted-foreground\">Compare plans</div>\n            {plans.map((plan) => (\n              <div\n                key={plan.id}\n                className={cn(\n                  \"relative flex flex-col items-start gap-1 p-3\",\n                  plan.recommended && \"bg-primary/5\",\n                )}\n              >\n                {plan.recommended ? (\n                  <>\n                    <span aria-hidden className=\"absolute inset-x-0 top-0 h-0.5 bg-primary\" />\n                    <span className=\"rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary-foreground\">\n                      {recommendedLabel}\n                    </span>\n                  </>\n                ) : (\n                  <span aria-hidden className=\"h-4\" />\n                )}\n                <span className=\"text-sm font-semibold\">{plan.name}</span>\n                <PlanPrice plan={plan} billing={billing} money={money} />\n                <Button\n                  size=\"sm\"\n                  variant={plan.recommended ? \"primary\" : \"secondary\"}\n                  onClick={() => onSelect?.(plan, billing)}\n                  className=\"mt-1 h-7 w-full px-2\"\n                >\n                  {plan.cta ?? \"Choose\"}\n                </Button>\n              </div>\n            ))}\n          </div>\n\n          {groups.map((group) => (\n            <Collapsible key={group.id} defaultOpen={openSet.has(group.id)}>\n              <GroupTrigger label={group.label} count={group.features.length} />\n              <CollapsibleContent>\n                {group.features.map((feature) => (\n                  <div\n                    key={feature.id}\n                    className=\"grid border-b border-border/60 last:border-b-0\"\n                    style={columns}\n                  >\n                    <FeatureLabel feature={feature} />\n                    {plans.map((plan) => (\n                      <div\n                        key={plan.id}\n                        className={cn(\n                          \"flex items-center justify-center px-2 py-2.5\",\n                          plan.recommended && \"bg-primary/5\",\n                        )}\n                      >\n                        <Cell value={feature.values[plan.id]} planName={plan.name} />\n                      </div>\n                    ))}\n                  </div>\n                ))}\n              </CollapsibleContent>\n            </Collapsible>\n          ))}\n        </div>\n\n        {/* Narrow layout: plan switcher + one value column */}\n        <div className=\"@xl:hidden\">\n          <div className=\"border-b border-border p-3\">\n            <SegmentedControl\n              aria-label=\"Plan\"\n              size=\"sm\"\n              fullWidth\n              value={current?.id}\n              onValueChange={setMobilePlan}\n              options={plans.map((p) => ({ value: p.id, label: p.name }))}\n            />\n            {current ? (\n              <div className=\"mt-3 flex items-center gap-3\">\n                <div className=\"min-w-0 flex-1\">\n                  <p className=\"flex items-center gap-2 text-sm font-semibold\">\n                    {current.name}\n                    {current.recommended ? (\n                      <span className=\"rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary-foreground\">\n                        {recommendedLabel}\n                      </span>\n                    ) : null}\n                  </p>\n                  <PlanPrice plan={current} billing={billing} money={money} />\n                </div>\n                <Button\n                  size=\"sm\"\n                  variant={current.recommended ? \"primary\" : \"secondary\"}\n                  onClick={() => onSelect?.(current, billing)}\n                >\n                  {current.cta ?? \"Choose\"}\n                </Button>\n              </div>\n            ) : null}\n          </div>\n          <div\n            className={cn(\n              \"overflow-y-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\",\n              bodyClassName ?? \"max-h-[22rem]\",\n            )}\n          >\n            {groups.map((group) => (\n              <Collapsible key={group.id} defaultOpen={openSet.has(group.id)}>\n                <GroupTrigger label={group.label} count={group.features.length} />\n                <CollapsibleContent>\n                  {group.features.map((feature) => (\n                    <div\n                      key={feature.id}\n                      className=\"grid grid-cols-[1fr_6rem] border-b border-border/60 last:border-b-0\"\n                    >\n                      <FeatureLabel feature={feature} />\n                      <div\n                        className={cn(\n                          \"flex items-center justify-center px-2 py-2.5\",\n                          current?.recommended && \"bg-primary/5\",\n                        )}\n                      >\n                        <AnimatePresence initial={false} mode=\"popLayout\">\n                          <motion.span\n                            key={current?.id}\n                            initial={{ opacity: 0, scale: 0.8 }}\n                            animate={{ opacity: 1, scale: 1 }}\n                            exit={{ opacity: 0, scale: 0.8 }}\n                            transition={{ duration: 0.18, ease: EASE_OUT }}\n                            className=\"flex\"\n                          >\n                            <Cell\n                              value={current ? feature.values[current.id] : undefined}\n                              planName={current?.name ?? \"\"}\n                            />\n                          </motion.span>\n                        </AnimatePresence>\n                      </div>\n                    </div>\n                  ))}\n                </CollapsibleContent>\n              </Collapsible>\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction PlanPrice({\n  plan,\n  billing,\n  money,\n}: {\n  plan: ComparisonPlan;\n  billing: ComparisonBilling;\n  money: Intl.NumberFormat;\n}) {\n  const amount = plan.price[billing];\n  const free = plan.price.monthly === 0 && plan.price.yearly === 0;\n  return (\n    <span className=\"flex items-baseline gap-0.5\">\n      <NumberTicker\n        value={Math.round(amount * 100)}\n        format={(cents) => money.format(cents / 100)}\n        duration={0.6}\n        stagger={0.03}\n        startOnView={false}\n        className=\"text-lg font-semibold tracking-tight\"\n      />\n      {free ? null : <span className=\"text-[11px] text-muted-foreground\">/mo</span>}\n    </span>\n  );\n}\n\nfunction GroupTrigger({ label, count }: { label: string; count: number }) {\n  const { open } = useCollapsible(\"GroupTrigger\");\n  const reduce = useReducedMotion();\n  return (\n    <CollapsibleTrigger className=\"flex w-full items-center gap-2 border-b border-border bg-muted/50 px-3 py-2 text-left text-xs font-semibold outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary\">\n      <motion.span\n        animate={{ rotate: open ? 0 : -90 }}\n        transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n        className=\"grid place-items-center text-muted-foreground\"\n      >\n        <ChevronDown aria-hidden className=\"size-3.5\" />\n      </motion.span>\n      <span className=\"flex-1\">{label}</span>\n      <span className=\"text-[10px] font-normal tabular-nums text-muted-foreground\">\n        {count} {count === 1 ? \"feature\" : \"features\"}\n      </span>\n    </CollapsibleTrigger>\n  );\n}\n\nfunction FeatureLabel({ feature }: { feature: ComparisonFeature }) {\n  return (\n    <div className=\"min-w-0 px-3 py-2.5\">\n      <p className=\"text-xs font-medium\">{feature.label}</p>\n      {feature.hint ? <p className=\"mt-0.5 text-[11px] text-muted-foreground\">{feature.hint}</p> : null}\n    </div>\n  );\n}\n\nfunction Cell({ value, planName }: { value: ComparisonValue | undefined; planName: string }) {\n  if (value === true) {\n    return (\n      <span className=\"grid size-5 place-items-center rounded-full bg-primary/15 text-primary\">\n        <Check aria-hidden className=\"size-3\" strokeWidth={3} />\n        <span className=\"sr-only\">{planName}: included</span>\n      </span>\n    );\n  }\n  if (value === false || value === undefined || value === null) {\n    return (\n      <span className=\"grid size-5 place-items-center text-muted-foreground/60\">\n        <Minus aria-hidden className=\"size-3.5\" />\n        <span className=\"sr-only\">{planName}: not included</span>\n      </span>\n    );\n  }\n  if (value === \"partial\") {\n    return (\n      <span className=\"grid size-5 place-items-center\">\n        <span\n          aria-hidden\n          className=\"size-3 rounded-full border-2 border-primary bg-[linear-gradient(90deg,var(--primary)_50%,transparent_50%)]\"\n        />\n        <span className=\"sr-only\">{planName}: partial</span>\n      </span>\n    );\n  }\n  return (\n    <span className=\"text-center text-xs text-foreground\">\n      <span className=\"sr-only\">{planName}: </span>\n      {value}\n    </span>\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/collapsible.tsx","type":"registry:component","target":"@components/motion/collapsible.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion, type Transition } from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  forwardRef,\n  isValidElement,\n  useCallback,\n  useContext,\n  useId,\n  useState,\n  type ButtonHTMLAttributes,\n  type HTMLAttributes,\n  type MouseEvent,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// Collapsible is the unstyled disclosure primitive: one trigger, one panel.\n// `Accordion` (accordion.tsx) is built from it; `BouncyAccordion` is the\n// springy, single-open icon-row variant. Reach for this when a single region\n// should show or hide in flow (\"Show 3 more\", an expandable activity row).\n\n/** Entrance is a touch longer than the exit — exits should feel faster. */\nconst OPEN_TRANSITION: Transition = { duration: 0.28, ease: EASE_OUT };\nconst CLOSE_TRANSITION: Transition = { duration: 0.2, ease: EASE_OUT };\n\ninterface CollapsibleContextValue {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  disabled: boolean;\n  triggerId: string;\n  contentId: string;\n}\n\nconst CollapsibleContext = createContext<CollapsibleContextValue | null>(null);\n\nexport function useCollapsible(component = \"useCollapsible\") {\n  const context = useContext(CollapsibleContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <Collapsible>.`);\n  }\n  return context;\n}\n\nexport interface CollapsibleProps\n  extends Omit<HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  /** Override the generated trigger id (the content is labelled by it). */\n  triggerId?: string;\n  /** Override the generated content id (the trigger controls it). */\n  contentId?: string;\n  children?: ReactNode;\n}\n\nexport const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(\n  function Collapsible(\n    {\n      open: openProp,\n      defaultOpen = false,\n      onOpenChange,\n      disabled = false,\n      triggerId: triggerIdProp,\n      contentId: contentIdProp,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const [internal, setInternal] = useState(defaultOpen);\n    const controlled = openProp !== undefined;\n    const open = controlled ? openProp : internal;\n    const setOpen = useCallback(\n      (next: boolean) => {\n        if (!controlled) setInternal(next);\n        onOpenChange?.(next);\n      },\n      [controlled, onOpenChange],\n    );\n    const baseId = useId();\n    const triggerId = triggerIdProp ?? `${baseId}-trigger`;\n    const contentId = contentIdProp ?? `${baseId}-content`;\n\n    return (\n      <CollapsibleContext.Provider\n        value={{ open, setOpen, disabled, triggerId, contentId }}\n      >\n        <div\n          ref={ref}\n          data-state={open ? \"open\" : \"closed\"}\n          data-disabled={disabled ? \"\" : undefined}\n          className={cn(className)}\n          {...rest}\n        >\n          {children}\n        </div>\n      </CollapsibleContext.Provider>\n    );\n  },\n);\n\ntype ClonableChild = ReactElement<{\n  onClick?: (event: MouseEvent<HTMLElement>) => void;\n  [key: string]: unknown;\n}>;\n\nexport interface CollapsibleTriggerProps\n  extends ButtonHTMLAttributes<HTMLButtonElement> {\n  /** Merge trigger behaviour onto the single child element instead of rendering a `<button>`. */\n  asChild?: boolean;\n}\n\n/** Toggles the nearest `Collapsible`. Unstyled `<button>` by default. */\nexport const CollapsibleTrigger = forwardRef<\n  HTMLButtonElement,\n  CollapsibleTriggerProps\n>(function CollapsibleTrigger(\n  { asChild, children, onClick, className, disabled: disabledProp, ...rest },\n  ref,\n) {\n  const context = useCollapsible(\"CollapsibleTrigger\");\n  const disabled = context.disabled || Boolean(disabledProp);\n  const shared = {\n    id: context.triggerId,\n    \"aria-expanded\": context.open,\n    \"aria-controls\": context.contentId,\n    \"data-state\": context.open ? \"open\" : \"closed\",\n    \"data-disabled\": disabled ? \"\" : undefined,\n  };\n\n  if (asChild && isValidElement(children)) {\n    const child = children as ClonableChild;\n    return cloneElement(child, {\n      ...shared,\n      onClick: (event: MouseEvent<HTMLElement>) => {\n        child.props.onClick?.(event);\n        if (!event.defaultPrevented && !disabled) context.setOpen(!context.open);\n      },\n    });\n  }\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      disabled={disabled}\n      className={cn(className)}\n      {...rest}\n      {...shared}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.setOpen(!context.open);\n      }}\n    >\n      {children}\n    </button>\n  );\n});\n\nexport interface CollapsibleContentProps extends HTMLAttributes<HTMLDivElement> {\n  /** Class for the inner wrapper that holds the children (padding goes here,\n   * never on the animated outer element, so the closed height is truly 0). */\n  innerClassName?: string;\n  /** Set to give the panel `role=\"region\"` labelled by its trigger. */\n  region?: boolean;\n  children?: ReactNode;\n}\n\n/** The disclosed panel. Stays mounted (inert while closed) so `aria-controls` always resolves. */\nexport function CollapsibleContent({\n  className,\n  innerClassName,\n  region = false,\n  children,\n  style,\n  ...rest\n}: CollapsibleContentProps) {\n  const { open, triggerId, contentId } = useCollapsible(\"CollapsibleContent\");\n  const reduce = useReducedMotion();\n  const fade = open ? OPEN_TRANSITION : CLOSE_TRANSITION;\n\n  return (\n    // Height is animated on purpose: disclosure is a layout job — the panel\n    // has to push the content below it down in flow, which transforms can't.\n    // Reduced motion snaps the height and keeps only the opacity fade.\n    <motion.div\n      {...(rest as Record<string, unknown>)}\n      id={contentId}\n      role={region ? \"region\" : undefined}\n      aria-labelledby={region ? triggerId : undefined}\n      data-state={open ? \"open\" : \"closed\"}\n      inert={!open}\n      initial={false}\n      animate={{ height: open ? \"auto\" : 0, opacity: open ? 1 : 0 }}\n      transition={\n        reduce ? { height: { duration: 0 }, opacity: { duration: 0.15 } } : fade\n      }\n      style={style}\n      className={cn(\"overflow-hidden\", className)}\n    >\n      <div className={innerClassName}>{children}</div>\n    </motion.div>\n  );\n}\n"},{"path":"components/motion/number-ticker.tsx","type":"registry:component","target":"@components/motion/number-ticker.tsx","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert en-US group separators (commas); fixed so server and client match. Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString(\"en-US\")\n        : rounded.toString();\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden\", className)}\n      style={{ height: `${DIGIT_HEIGHT_EM}em`, width: \"1ch\" }}\n    >\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"components/motion/segmented-control.tsx","type":"registry:component","target":"@components/motion/segmented-control.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  useCallback,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// SegmentedControl vs Tabs: `Tabs` (tabs.tsx, variants pill/segment/underline)\n// switches *panels* with tablist/tab semantics. `SegmentedControl` is a form\n// control: it picks one *value* with radiogroup/radio semantics, can post that\n// value with a form via `name`, supports disabled options, icon-only options\n// and a full-width layout, and its thumb is a raised card surface rather than\n// a filled accent pill. Reach for Tabs when each option owns content below it.\n\nexport type SegmentedControlSize = \"sm\" | \"md\" | \"lg\";\n\nexport interface SegmentedControlOption {\n  value: string;\n  label?: ReactNode;\n  icon?: ReactNode;\n  disabled?: boolean;\n  /** Required when the option has only an icon. */\n  \"aria-label\"?: string;\n}\n\nconst trackSize: Record<SegmentedControlSize, string> = {\n  sm: \"h-8 p-0.5 text-xs\",\n  md: \"h-10 p-1 text-sm\",\n  lg: \"h-12 p-1 text-sm\",\n};\n\nconst itemSize: Record<SegmentedControlSize, string> = {\n  sm: \"gap-1.5 px-2.5 [&_svg]:size-3.5\",\n  md: \"gap-2 px-3.5 [&_svg]:size-4\",\n  lg: \"gap-2 px-5 [&_svg]:size-[18px]\",\n};\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState<T>(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\n/** Index of the next enabled option from `from` in `dir`, wrapping around. */\nfunction nextEnabled(options: { disabled?: boolean }[], from: number, dir: 1 | -1) {\n  const n = options.length;\n  for (let step = 1; step <= n; step++) {\n    const i = (((from + dir * step) % n) + n) % n;\n    if (!options[i].disabled) return i;\n  }\n  return from;\n}\n\nexport interface SegmentedControlProps {\n  options: SegmentedControlOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  size?: SegmentedControlSize;\n  /** Stretch to the container width with equal-width segments. */\n  fullWidth?: boolean;\n  disabled?: boolean;\n  /** When set, a hidden input posts the value with the surrounding form. */\n  name?: string;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n  thumbClassName?: string;\n}\n\nexport function SegmentedControl({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  size = \"md\",\n  fullWidth = false,\n  disabled = false,\n  name,\n  className,\n  thumbClassName,\n  ...aria\n}: SegmentedControlProps) {\n  const [current, setValue] = useControllable(\n    value,\n    defaultValue ?? options.find((o) => !o.disabled)?.value ?? \"\",\n    onValueChange,\n  );\n  const reduce = useReducedMotion();\n  const layoutId = useId();\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const items = options.map((o) => ({ ...o, disabled: disabled || o.disabled }));\n  const selectedIndex = items.findIndex((o) => o.value === current);\n  // Roving tab stop: the checked radio, or the first enabled one when none is.\n  const tabStop = selectedIndex >= 0 && !items[selectedIndex].disabled\n    ? selectedIndex\n    : items.findIndex((o) => !o.disabled);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    let next: number | null = null;\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        next = nextEnabled(items, index, 1);\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        next = nextEnabled(items, index, -1);\n        break;\n      case \"Home\":\n        next = nextEnabled(items, -1, 1);\n        break;\n      case \"End\":\n        next = nextEnabled(items, items.length, -1);\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    if (next === null || items[next].disabled) return;\n    // Selection follows focus, as the WAI-ARIA radio group pattern prescribes.\n    refs.current[next]?.focus();\n    if (items[next].value !== current) setValue(items[next].value);\n  };\n\n  return (\n    // layoutRoot keeps the thumb's shared-layout projection local, so scrolled\n    // or fixed ancestors never leak offsets into its glide.\n    <motion.div\n      layoutRoot\n      role=\"radiogroup\"\n      aria-disabled={disabled || undefined}\n      {...aria}\n      className={cn(\n        \"relative isolate items-stretch rounded-xl border border-border bg-muted\",\n        fullWidth ? \"flex w-full\" : \"inline-flex\",\n        trackSize[size],\n        disabled && \"opacity-60\",\n        className,\n      )}\n    >\n      {name ? <input type=\"hidden\" name={name} value={current} /> : null}\n      {items.map((option, index) => {\n        const checked = option.value === current;\n        const iconOnly = !option.label;\n        return (\n          <motion.button\n            key={option.value}\n            ref={(el) => {\n              refs.current[index] = el;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            aria-label={option[\"aria-label\"]}\n            disabled={option.disabled}\n            tabIndex={index === tabStop ? 0 : -1}\n            data-state={checked ? \"checked\" : \"unchecked\"}\n            onClick={() => !checked && setValue(option.value)}\n            onKeyDown={(e) => onKeyDown(e, index)}\n            whileTap={reduce || option.disabled ? undefined : { scale: 0.96 }}\n            transition={SPRING_PRESS}\n            className={cn(\n              \"relative inline-flex min-w-0 select-none items-center justify-center whitespace-nowrap rounded-[9px] font-medium outline-none transition-colors duration-200\",\n              \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-muted\",\n              \"disabled:cursor-not-allowed disabled:opacity-50\",\n              fullWidth && \"flex-1\",\n              itemSize[size],\n              iconOnly && \"aspect-square px-0\",\n              checked\n                ? \"text-foreground\"\n                : \"text-muted-foreground enabled:hover:text-foreground\",\n            )}\n          >\n            {checked ? (\n              <motion.span\n                layoutId={layoutId}\n                aria-hidden\n                transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                className={cn(\n                  \"absolute inset-0 -z-10 rounded-[9px] border border-border bg-card shadow-sm\",\n                  thumbClassName,\n                )}\n              />\n            ) : null}\n            {option.icon ? (\n              <span aria-hidden className=\"inline-flex shrink-0\">\n                {option.icon}\n              </span>\n            ) : null}\n            {option.label ? <span className=\"truncate\">{option.label}</span> : null}\n          </motion.button>\n        );\n      })}\n    </motion.div>\n  );\n}\n\n/* ------------------------------------------------------------------------ */\n/* ToggleGroup                                                               */\n/* ------------------------------------------------------------------------ */\n\nexport interface ToggleGroupItem {\n  value: string;\n  label?: ReactNode;\n  icon?: ReactNode;\n  disabled?: boolean;\n  /** Required when the item has only an icon. */\n  \"aria-label\"?: string;\n}\n\ntype ToggleGroupBase = {\n  items: ToggleGroupItem[];\n  size?: SegmentedControlSize;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n};\n\nexport type ToggleGroupSingleProps = ToggleGroupBase & {\n  type: \"single\";\n  /** The pressed item, or `\"\"` when none is pressed. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Disallow un-pressing the active item, so one is always pressed. */\n  required?: boolean;\n};\n\nexport type ToggleGroupMultipleProps = ToggleGroupBase & {\n  type: \"multiple\";\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n};\n\nexport type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;\n\nconst toggleSize: Record<SegmentedControlSize, string> = {\n  sm: \"h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5\",\n  md: \"h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4\",\n  lg: \"h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]\",\n};\n\nexport function ToggleGroup(props: ToggleGroupProps) {\n  const { items, size = \"md\", disabled = false, className } = props;\n  const reduce = useReducedMotion();\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const [focusIndex, setFocusIndex] = useState<number | null>(null);\n\n  // Normalise both modes to a string[] internally.\n  const single = props.type === \"single\";\n  const [current, setCurrent] = useControllable<string[]>(\n    props.value === undefined ? undefined : single ? (props.value ? [props.value as string] : []) : (props.value as string[]),\n    single\n      ? props.defaultValue\n        ? [props.defaultValue as string]\n        : []\n      : ((props.defaultValue as string[] | undefined) ?? []),\n    (next) => {\n      if (props.type === \"single\") props.onValueChange?.(next[0] ?? \"\");\n      else props.onValueChange?.(next);\n    },\n  );\n\n  const list = items.map((i) => ({ ...i, disabled: disabled || i.disabled }));\n  const firstEnabled = list.findIndex((i) => !i.disabled);\n  const tabStop =\n    focusIndex !== null && !list[focusIndex]?.disabled ? focusIndex : firstEnabled;\n\n  const toggle = (itemValue: string) => {\n    const pressed = current.includes(itemValue);\n    if (props.type === \"single\") {\n      if (pressed && props.required) return;\n      setCurrent(pressed ? [] : [itemValue]);\n    } else {\n      setCurrent(pressed ? current.filter((v) => v !== itemValue) : [...current, itemValue]);\n    }\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    let next: number | null = null;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") next = nextEnabled(list, index, 1);\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") next = nextEnabled(list, index, -1);\n    else if (event.key === \"Home\") next = nextEnabled(list, -1, 1);\n    else if (event.key === \"End\") next = nextEnabled(list, list.length, -1);\n    if (next === null) return;\n    event.preventDefault();\n    // Focus moves; pressing stays on Space/Enter (toggle buttons, not radios).\n    setFocusIndex(next);\n    refs.current[next]?.focus();\n  };\n\n  return (\n    <motion.div\n      role=\"group\"\n      aria-label={props[\"aria-label\"]}\n      aria-labelledby={props[\"aria-labelledby\"]}\n      className={cn(\n        \"inline-flex items-center gap-0.5 rounded-xl border border-border bg-card p-1\",\n        disabled && \"opacity-60\",\n        className,\n      )}\n    >\n      {list.map((item, index) => {\n        const pressed = current.includes(item.value);\n        return (\n          <motion.button\n            key={item.value}\n            ref={(el) => {\n              refs.current[index] = el;\n            }}\n            type=\"button\"\n            aria-pressed={pressed}\n            aria-label={item[\"aria-label\"]}\n            disabled={item.disabled}\n            tabIndex={index === tabStop ? 0 : -1}\n            data-state={pressed ? \"on\" : \"off\"}\n            onClick={() => {\n              setFocusIndex(index);\n              toggle(item.value);\n            }}\n            onFocus={() => setFocusIndex(index)}\n            onKeyDown={(e) => onKeyDown(e, index)}\n            whileTap={reduce || item.disabled ? undefined : { scale: 0.88 }}\n            transition={SPRING_PRESS}\n            className={cn(\n              \"relative isolate inline-flex select-none items-center justify-center rounded-lg font-medium outline-none transition-colors duration-200\",\n              \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card\",\n              \"disabled:cursor-not-allowed disabled:opacity-50\",\n              toggleSize[size],\n              pressed\n                ? \"text-foreground\"\n                : \"text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground\",\n            )}\n          >\n            <AnimatePresence initial={false}>\n              {pressed ? (\n                <motion.span\n                  key=\"on\"\n                  aria-hidden\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }}\n                  transition={SPRING_SWAP}\n                  className=\"absolute inset-0 -z-10 rounded-lg bg-muted ring-1 ring-border-strong\"\n                />\n              ) : null}\n            </AnimatePresence>\n            {item.icon ? (\n              <span aria-hidden className=\"inline-flex shrink-0\">\n                {item.icon}\n              </span>\n            ) : null}\n            {item.label ? <span>{item.label}</span> : null}\n          </motion.button>\n        );\n      })}\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/button/base.tsx","type":"registry:component","target":"@components/motion/button/base.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"outline\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\n\nexport interface ButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  /** Spawn a Material-style ripple from the press point. Off by default. */\n  ripple?: boolean;\n  children?: ReactNode;\n}\n\nexport interface ButtonLinkProps extends Omit<\n  HTMLMotionProps<\"a\">,\n  \"children\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  pressScale?: number;\n  children?: ReactNode;\n}\n\ntype Ripple = { id: number; x: number; y: number; size: number };\n\nconst VARIANT_CLASS: Record<ButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  ghost: \"text-muted-foreground hover:text-foreground hover:bg-primary/5\",\n  outline:\n    \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n};\n\nconst SIZE_CLASS: Record<ButtonSize, string> = {\n  sm: \"h-8 px-3 text-xs gap-1.5 rounded-full\",\n  md: \"h-10 px-5 text-sm gap-2 rounded-full\",\n  lg: \"h-12 px-6 text-base gap-2 rounded-full\",\n  icon: \"h-8 w-8 rounded-lg\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  function Button(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      ripple = false,\n      className,\n      children,\n      onPointerDown,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n    const [ripples, setRipples] = useState<Ripple[]>([]);\n    const nextId = useRef(0);\n\n    const handlePointerDown = useCallback(\n      (event: PointerEvent<HTMLButtonElement>) => {\n        if (ripple && !reduce) {\n          const rect = event.currentTarget.getBoundingClientRect();\n          const size = Math.max(rect.width, rect.height) * 2;\n          const id = nextId.current++;\n          setRipples((prev) => [\n            ...prev,\n            {\n              id,\n              x: event.clientX - rect.left,\n              y: event.clientY - rect.top,\n              size,\n            },\n          ]);\n        }\n        onPointerDown?.(event);\n      },\n      [ripple, reduce, onPointerDown],\n    );\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        onPointerDown={handlePointerDown}\n        className={cn(\n          \"relative inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          // The ripple layer clips itself, so on a touch screen the button can\n          // stop clipping and let its enlarged hit area overhang.\n          ripple && \"overflow-hidden pointer-coarse:overflow-visible\",\n          TOUCH_TARGET_CLASS,\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {ripple && !reduce ? (\n          <span className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]\">\n            <AnimatePresence>\n              {ripples.map((r) => (\n                <motion.span\n                  key={r.id}\n                  className=\"absolute rounded-full bg-current\"\n                  style={{\n                    left: r.x,\n                    top: r.y,\n                    width: r.size,\n                    height: r.size,\n                    x: \"-50%\",\n                    y: \"-50%\",\n                  }}\n                  initial={{ scale: 0.05, opacity: 0.3 }}\n                  animate={{ scale: 1, opacity: 0 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 1.6, ease: EASE_OUT }}\n                  onAnimationComplete={() =>\n                    setRipples((prev) => prev.filter((x) => x.id !== r.id))\n                  }\n                />\n              ))}\n            </AnimatePresence>\n          </span>\n        ) : null}\n        {children}\n      </motion.button>\n    );\n  },\n);\n\nexport const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(\n  function ButtonLink(\n    {\n      variant = \"primary\",\n      size = \"md\",\n      pressScale = 0.93,\n      className,\n      children,\n      ...rest\n    },\n    ref,\n  ) {\n    const reduce = useReducedMotion();\n    const canHover = useHoverCapable();\n\n    return (\n      <motion.a\n        ref={ref}\n        whileTap={reduce ? undefined : { scale: pressScale }}\n        whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"relative inline-flex items-center justify-center font-medium select-none\",\n          \"transition-colors\",\n          TOUCH_TARGET_CLASS,\n          VARIANT_CLASS[variant],\n          SIZE_CLASS[size],\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.a>\n    );\n  },\n);\n"},{"path":"components/motion/button/magnetic.tsx","type":"registry:component","target":"@components/motion/button/magnetic.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport { Magnetic } from \"../magnetic\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport interface MagneticButtonProps extends ButtonProps {\n  /** Magnetic pull strength. Default 0.25. */\n  strength?: number;\n  /** Class applied to the magnetic wrapper. */\n  magneticClassName?: string;\n}\n\nexport const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(\n  { strength = 0.25, magneticClassName, children, ...rest },\n  ref,\n) {\n  return (\n    <Magnetic strength={strength} className={magneticClassName}>\n      <Button ref={ref} {...rest}>\n        {children}\n      </Button>\n    </Magnetic>\n  );\n});\n"},{"path":"components/motion/button/stateful.tsx","type":"registry:component","target":"@components/motion/button/stateful.tsx","content":"\"use client\";\n\nimport { Check, Loader2, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  type ReactNode,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { Button, type ButtonProps } from \"./base\";\n\nexport type ButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"children\"> {\n  state?: ButtonState;\n  children: ReactNode;\n  loadingText?: ReactNode;\n  successText?: ReactNode;\n  errorText?: ReactNode;\n  icon?: ReactNode;\n}\n\nconst CASCADE_STAGGER = 0.025;\nconst ROLL_BLUR = \"blur(6px)\";\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst ICON_VARIANTS: Variants = {\n  // Width collapses too, so the icon adds/removes its own space smoothly\n  // instead of popping the row width in a single frame.\n  initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },\n  animate: {\n    opacity: 1,\n    width: \"1.5rem\",\n    scale: 1,\n    filter: \"blur(0px)\",\n    transition: SPRING_SWAP,\n  },\n  exit: {\n    opacity: 0,\n    width: 0,\n    scale: 0.7,\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT },\n  },\n};\n\nfunction IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.span\n      key={keyId}\n      variants={ICON_VARIANTS}\n      initial={reduce ? { opacity: 0 } : \"initial\"}\n      animate={reduce ? { opacity: 1 } : \"animate\"}\n      exit={reduce ? { opacity: 0 } : \"exit\"}\n      transition={reduce ? { duration: 0.15 } : undefined}\n      className=\"inline-grid shrink-0 place-items-center overflow-hidden\"\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nfunction TextSlot({\n  value,\n  children,\n}: {\n  value: string;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = label !== null && !reduce;\n\n  // Measure strings with the same per-letter layout as the cascade. Measuring\n  // the whole string preserves kerning, which can make it narrower than the\n  // inline-block letters and clip the final glyph during the width animation.\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((current) => (current === nextWidth ? current : nextWidth));\n  });\n\n  return (\n    <motion.span\n      initial={false}\n      animate={{ width }}\n      transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      className=\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\"\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {cascade\n          ? label.split(\"\").map((char, index) => (\n              <span\n                // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                key={index}\n                className=\"inline-block whitespace-pre\"\n              >\n                {char}\n              </span>\n            ))\n          : children}\n      </span>\n\n      {cascade ? (\n        <>\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, index) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.\n                  key={index}\n                  custom={index * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`text-${value}`}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </motion.span>\n  );\n}\n\nexport const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(\n  {\n    state = \"idle\",\n    children,\n    loadingText = \"Loading\",\n    successText = \"Done\",\n    errorText = \"Try again\",\n    icon,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const isBusy = state === \"loading\";\n  const stateText =\n    state === \"loading\"\n      ? loadingText\n      : state === \"success\"\n        ? successText\n        : state === \"error\"\n        ? errorText\n        : children;\n  const textKey =\n    typeof stateText === \"string\" ? `${state}-${stateText}` : state;\n\n  return (\n    <Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>\n      <span\n        aria-live=\"polite\"\n        className=\"relative inline-flex items-center justify-center overflow-hidden\"\n      >\n        <AnimatePresence initial={false}>\n          {state === \"loading\" ? (\n            <IconSlot keyId=\"loading-icon\">\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n            </IconSlot>\n          ) : null}\n          {state === \"success\" ? (\n            <IconSlot keyId=\"success-icon\">\n              <Check className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n          {state === \"error\" ? (\n            <IconSlot keyId=\"error-icon\">\n              <X className=\"h-4 w-4\" />\n            </IconSlot>\n          ) : null}\n        </AnimatePresence>\n\n        <TextSlot value={textKey}>{stateText}</TextSlot>\n\n        <AnimatePresence initial={false}>\n          {state === \"idle\" && icon ? (\n            <IconSlot keyId=\"idle-icon\">{icon}</IconSlot>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Button>\n  );\n});\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"components/motion/magnetic.tsx","type":"registry:component","target":"@components/motion/magnetic.tsx","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"}]}