{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"checkout","type":"registry:block","title":"Checkout","description":"Checkout flow on Stepper — shipping with a gliding delivery-speed choice, display-only payment fields and an editable review — beside an order summary with compact quantity steppers, a promo code that shakes when invalid and pops a removable badge when valid, and subtotal, discount, tax and total that roll on every change.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/checkout.tsx","type":"registry:component","target":"@components/motion/checkout.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/checkout\n\nimport { Lock, Minus, Plus, Tag, Truck, Zap } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport { type ReactNode, useCallback, useId, useMemo, useRef, useState } from \"react\";\nimport { Badge } from \"@/components/motion/badge\";\nimport { Button, StatefulButton } from \"@/components/motion/button\";\nimport { Input } from \"@/components/motion/input\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport {\n  Stepper,\n  StepperIndicator,\n  StepperNext,\n  StepperPanels,\n  StepperPrevious,\n  type StepperStep,\n  useStepper,\n} from \"@/components/motion/stepper\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CheckoutItem = {\n  id: string;\n  name: string;\n  /** Secondary line — size, color, plan. */\n  variant?: string;\n  /** Unit price in major units (12.5 = $12.50). */\n  price: number;\n  quantity: number;\n  /** Thumbnail node; defaults to a tinted swatch. */\n  image?: ReactNode;\n};\n\nexport type CheckoutPromo = {\n  code: string;\n  label: string;\n  /** Percentage off the subtotal (0–100). */\n  percentOff?: number;\n  /** Fixed amount off, in major units. */\n  amountOff?: number;\n};\n\nexport type CheckoutShipping = {\n  name: string;\n  address: string;\n  city: string;\n  zip: string;\n};\n\nexport type CheckoutDelivery = \"standard\" | \"express\";\n\nexport type CheckoutTotals = {\n  subtotal: number;\n  discount: number;\n  shipping: number;\n  tax: number;\n  total: number;\n};\n\nexport type CheckoutOrder = {\n  items: CheckoutItem[];\n  shipping: CheckoutShipping;\n  delivery: CheckoutDelivery;\n  promo: CheckoutPromo | null;\n  totals: CheckoutTotals;\n};\n\nexport interface CheckoutProps {\n  items?: CheckoutItem[];\n  defaultItems?: CheckoutItem[];\n  onItemsChange?: (items: CheckoutItem[]) => void;\n  /** Known codes (case-insensitive) when `onApplyPromo` isn't given. */\n  promoCodes?: CheckoutPromo[];\n  /** Async promo check. Resolve with the promo, reject to mark the code invalid. */\n  onApplyPromo?: (code: string) => Promise<CheckoutPromo>;\n  /**\n   * Places the order. Card fields are UI only and never included — wire a\n   * payment provider's hosted fields for real card entry. Resolve with an\n   * order reference to show it; reject to show the message on the review step.\n   */\n  onComplete?: (order: CheckoutOrder) => Promise<string | undefined> | string | undefined;\n  /** Sales tax rate (0.08 = 8%). Default 0.08. */\n  taxRate?: number;\n  /** Express delivery price in major units. Default 15. */\n  expressPrice?: number;\n  currency?: string;\n  locale?: string;\n  className?: string;\n}\n\nconst STEPS: StepperStep[] = [\n  { id: \"shipping\", title: \"Shipping\" },\n  { id: \"payment\", title: \"Payment\" },\n  { id: \"review\", title: \"Review\" },\n];\n\nconst SWATCHES = [\"bg-chart-1\", \"bg-chart-2\", \"bg-chart-3\", \"bg-chart-4\", \"bg-chart-5\"];\nconst SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };\nconst EMPTY_SHIPPING: CheckoutShipping = { name: \"\", address: \"\", city: \"\", zip: \"\" };\n\nconst cents = (major: number) => Math.round(major * 100);\n\nfunction digits(value: string, max: number) {\n  return value.replace(/\\D/g, \"\").slice(0, max);\n}\n\nfunction messageOf(reason: unknown, fallback: string) {\n  if (reason instanceof Error && reason.message) return reason.message;\n  if (typeof reason === \"string\" && reason) return reason;\n  return fallback;\n}\n\n/** Money that rolls when it changes. Values are cents so decimals survive `NumberTicker`'s rounding. */\nfunction Money({\n  value,\n  format,\n  className,\n}: {\n  value: number;\n  format: (cents: number) => string;\n  className?: string;\n}) {\n  return (\n    <NumberTicker\n      value={value}\n      format={format}\n      startOnView={false}\n      duration={0.5}\n      stagger={0.015}\n      className={cn(\"tabular-nums\", className)}\n    />\n  );\n}\n\n/**\n * Compact quantity control. `NumberStepper`'s 48px editable field is too big\n * for a summary line, so this keeps two pressable buttons around a rolling\n * digit and exposes the same spinbutton semantics.\n */\nfunction Quantity({\n  value,\n  onChange,\n  label,\n  min = 1,\n  max = 9,\n}: {\n  value: number;\n  onChange: (next: number) => void;\n  label: string;\n  min?: number;\n  max?: number;\n}) {\n  const reduce = useReducedMotion();\n  const button = (dir: -1 | 1) => {\n    const Icon = dir < 0 ? Minus : Plus;\n    const blocked = dir < 0 ? value <= min : value >= max;\n    return (\n      <motion.button\n        type=\"button\"\n        aria-label={`${dir < 0 ? \"Decrease\" : \"Increase\"} ${label}`}\n        disabled={blocked}\n        whileTap={reduce || blocked ? undefined : { scale: 0.85 }}\n        transition={SPRING_PRESS}\n        onClick={() => onChange(value + dir)}\n        className=\"grid size-6 place-items-center rounded-full text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:text-muted-foreground/50 disabled:hover:bg-transparent\"\n      >\n        <Icon className=\"size-3\" aria-hidden />\n      </motion.button>\n    );\n  };\n  return (\n    <div className=\"inline-flex items-center gap-0.5 rounded-full border border-border bg-background p-0.5\">\n      {button(-1)}\n      <span\n        role=\"spinbutton\"\n        tabIndex={0}\n        aria-label={label}\n        aria-valuenow={value}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowUp\" && value < max) {\n            event.preventDefault();\n            onChange(value + 1);\n          } else if (event.key === \"ArrowDown\" && value > min) {\n            event.preventDefault();\n            onChange(value - 1);\n          }\n        }}\n        className=\"min-w-4 rounded text-center text-xs font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <NumberTicker value={value} startOnView={false} duration={0.35} stagger={0} />\n      </span>\n      {button(1)}\n    </div>\n  );\n}\n\n/** Finish button that shows the total and the stepper's pending state. */\nfunction PlaceOrder({ label }: { label: string }) {\n  const { isLast, pending } = useStepper();\n  return (\n    <StepperNext finishLabel={pending && isLast ? \"Placing order…\" : label} size=\"md\">\n      Continue\n    </StepperNext>\n  );\n}\n\n/**\n * Checkout block: `Stepper`-driven Shipping → Payment → Review with guarded\n * steps that shake when incomplete, an order summary with compact quantity\n * controls, a promo field that shakes on an invalid code and pops a removable\n * `Badge` on a valid one, and totals that roll through `NumberTicker`. Card\n * inputs are display-only placeholders and are never passed to `onComplete`.\n */\nexport function Checkout({\n  items: itemsProp,\n  defaultItems = [],\n  onItemsChange,\n  promoCodes = [],\n  onApplyPromo,\n  onComplete,\n  taxRate = 0.08,\n  expressPrice = 15,\n  currency = \"USD\",\n  locale = \"en-US\",\n  className,\n}: CheckoutProps) {\n  const reduce = useReducedMotion();\n  const baseId = useId();\n\n  const [internalItems, setInternalItems] = useState(defaultItems);\n  const items = itemsProp ?? internalItems;\n  const setQuantity = (id: string, quantity: number) => {\n    const next = items.map((item) => (item.id === id ? { ...item, quantity } : item));\n    if (itemsProp === undefined) setInternalItems(next);\n    onItemsChange?.(next);\n  };\n\n  const [shipping, setShipping] = useState<CheckoutShipping>(EMPTY_SHIPPING);\n  const [delivery, setDelivery] = useState<CheckoutDelivery>(\"standard\");\n  // UI-only card fields: local state, never leaves this component.\n  const [card, setCard] = useState({ number: \"\", expiry: \"\", cvc: \"\" });\n  const [touched, setTouched] = useState<{ shipping?: boolean; payment?: boolean }>({});\n\n  const [promoInput, setPromoInput] = useState(\"\");\n  const [promo, setPromo] = useState<CheckoutPromo | null>(null);\n  const [promoState, setPromoState] = useState<\"idle\" | \"loading\" | \"success\" | \"error\">(\n    \"idle\",\n  );\n  const [promoError, setPromoError] = useState<string>();\n  const promoRef = useRef<HTMLFormElement>(null);\n\n  const [placed, setPlaced] = useState<{ reference?: string } | null>(null);\n  const [orderError, setOrderError] = useState<string>();\n\n  const formatter = useMemo(\n    () => new Intl.NumberFormat(locale, { style: \"currency\", currency }),\n    [locale, currency],\n  );\n  const format = useCallback((c: number) => formatter.format(c / 100), [formatter]);\n\n  const totals = useMemo(() => {\n    const subtotal = items.reduce((sum, item) => sum + cents(item.price) * item.quantity, 0);\n    const discount = promo\n      ? Math.min(\n          subtotal,\n          promo.percentOff\n            ? Math.round((subtotal * promo.percentOff) / 100)\n            : cents(promo.amountOff ?? 0),\n        )\n      : 0;\n    const ship = delivery === \"express\" ? cents(expressPrice) : 0;\n    const tax = Math.round((subtotal - discount) * taxRate);\n    return { subtotal, discount, shipping: ship, tax, total: subtotal - discount + ship + tax };\n  }, [items, promo, delivery, expressPrice, taxRate]);\n\n  const shippingErrors = {\n    name: shipping.name.trim() ? undefined : \"Required\",\n    address: shipping.address.trim() ? undefined : \"Required\",\n    city: shipping.city.trim() ? undefined : \"Required\",\n    zip: /^[A-Za-z0-9 -]{3,10}$/.test(shipping.zip.trim()) ? undefined : \"Invalid\",\n  };\n  const [mm, yy] = card.expiry.split(\"/\");\n  const paymentErrors = {\n    number: digits(card.number, 16).length === 16 ? undefined : \"Enter 16 digits\",\n    expiry:\n      Number(mm) >= 1 && Number(mm) <= 12 && (yy ?? \"\").length === 2 ? undefined : \"MM/YY\",\n    cvc: /^\\d{3,4}$/.test(card.cvc) ? undefined : \"3–4 digits\",\n  };\n  const hasErrors = (errors: Record<string, string | undefined>) =>\n    Object.values(errors).some(Boolean);\n\n  const canAdvance = (index: number) => {\n    if (index === 0) {\n      setTouched((t) => ({ ...t, shipping: true }));\n      return !hasErrors(shippingErrors);\n    }\n    if (index === 1) {\n      setTouched((t) => ({ ...t, payment: true }));\n      return !hasErrors(paymentErrors);\n    }\n    return items.length > 0;\n  };\n\n  const complete = async () => {\n    setOrderError(undefined);\n    try {\n      const reference = await onComplete?.({\n        items,\n        shipping,\n        delivery,\n        promo,\n        totals: {\n          subtotal: totals.subtotal / 100,\n          discount: totals.discount / 100,\n          shipping: totals.shipping / 100,\n          tax: totals.tax / 100,\n          total: totals.total / 100,\n        },\n      });\n      setPlaced({ reference: reference ?? undefined });\n    } catch (reason) {\n      setOrderError(messageOf(reason, \"Payment didn't go through. Try again.\"));\n    }\n  };\n\n  const applyPromo = async () => {\n    const code = promoInput.trim();\n    if (!code) return;\n    setPromoState(\"loading\");\n    setPromoError(undefined);\n    try {\n      let result: CheckoutPromo | undefined;\n      if (onApplyPromo) result = await onApplyPromo(code);\n      else result = promoCodes.find((p) => p.code.toLowerCase() === code.toLowerCase());\n      if (!result) throw new Error(\"That code isn't valid.\");\n      setPromo(result);\n      setPromoInput(\"\");\n      setPromoState(\"idle\");\n    } catch (reason) {\n      setPromoState(\"idle\");\n      setPromoError(messageOf(reason, \"That code isn't valid.\"));\n      if (!reduce && promoRef.current) {\n        animate(promoRef.current, SHAKE, { duration: 0.42, ease: EASE_OUT });\n      }\n    }\n  };\n\n  const fieldError = (show: boolean | undefined, error: string | undefined) =>\n    show ? error : undefined;\n\n  const shippingPanel = (\n    <div className=\"flex flex-col gap-2.5\">\n      <Input\n        aria-label=\"Full name\"\n        placeholder=\"Full name\"\n        autoComplete=\"name\"\n        value={shipping.name}\n        onChange={(name) => setShipping((s) => ({ ...s, name }))}\n        error={fieldError(touched.shipping, shippingErrors.name) && true}\n      />\n      <Input\n        aria-label=\"Street address\"\n        placeholder=\"Street address\"\n        autoComplete=\"street-address\"\n        value={shipping.address}\n        onChange={(address) => setShipping((s) => ({ ...s, address }))}\n        error={fieldError(touched.shipping, shippingErrors.address) && true}\n      />\n      <div className=\"grid grid-cols-[minmax(0,1fr)_7rem] gap-2.5\">\n        <Input\n          aria-label=\"City\"\n          placeholder=\"City\"\n          autoComplete=\"address-level2\"\n          value={shipping.city}\n          onChange={(city) => setShipping((s) => ({ ...s, city }))}\n          error={fieldError(touched.shipping, shippingErrors.city) && true}\n        />\n        <Input\n          aria-label=\"Postal code\"\n          placeholder=\"ZIP\"\n          autoComplete=\"postal-code\"\n          value={shipping.zip}\n          onChange={(zip) => setShipping((s) => ({ ...s, zip }))}\n          error={fieldError(touched.shipping, shippingErrors.zip) && true}\n        />\n      </div>\n      <fieldset className=\"m-0 grid grid-cols-2 gap-2.5 border-0 p-0\">\n        <legend className=\"sr-only\">Delivery speed</legend>\n        {(\n          [\n            { id: \"standard\", label: \"Standard\", note: \"Free · 5–7 days\", icon: Truck },\n            {\n              id: \"express\",\n              label: \"Express\",\n              note: `${format(cents(expressPrice))} · 1–2 days`,\n              icon: Zap,\n            },\n          ] as const\n        ).map((option) => {\n          const active = delivery === option.id;\n          const Icon = option.icon;\n          return (\n            <label\n              key={option.id}\n              className={cn(\n                \"relative flex cursor-pointer items-center gap-2.5 rounded-2xl border px-3 py-2 transition-colors has-focus-visible:ring-2 has-focus-visible:ring-ring\",\n                active ? \"border-foreground/40\" : \"border-border hover:border-border-strong\",\n              )}\n            >\n              <input\n                type=\"radio\"\n                name={`${baseId}-delivery`}\n                value={option.id}\n                checked={active}\n                onChange={() => setDelivery(option.id)}\n                className=\"sr-only\"\n              />\n              {active ? (\n                <motion.span\n                  layoutId={`${baseId}-delivery-pill`}\n                  transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                  className=\"absolute inset-0 rounded-2xl bg-muted\"\n                  aria-hidden\n                />\n              ) : null}\n              <Icon className=\"relative size-4 shrink-0 text-muted-foreground\" aria-hidden />\n              <span className=\"relative min-w-0\">\n                <span className=\"block text-sm font-medium text-foreground\">{option.label}</span>\n                <span className=\"block truncate text-xs text-muted-foreground\">\n                  {option.note}\n                </span>\n              </span>\n            </label>\n          );\n        })}\n      </fieldset>\n    </div>\n  );\n\n  const paymentPanel = (\n    <div className=\"flex flex-col gap-2.5\">\n      {/* Placeholders only: autocomplete is off so no saved card is ever\n          offered, and these values never leave the component. */}\n      <Input\n        aria-label=\"Card number\"\n        placeholder=\"1234 1234 1234 1234\"\n        inputMode=\"numeric\"\n        autoComplete=\"off\"\n        leftIcon={<Lock />}\n        value={digits(card.number, 16).replace(/(\\d{4})(?=\\d)/g, \"$1 \")}\n        onChange={(number) => setCard((c) => ({ ...c, number: digits(number, 16) }))}\n        error={fieldError(touched.payment, paymentErrors.number) && true}\n      />\n      <div className=\"grid grid-cols-2 gap-2.5\">\n        <Input\n          aria-label=\"Expiry (MM/YY)\"\n          placeholder=\"MM / YY\"\n          inputMode=\"numeric\"\n          autoComplete=\"off\"\n          value={card.expiry}\n          onChange={(next) => {\n            const d = digits(next, 4);\n            setCard((c) => ({ ...c, expiry: d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d }));\n          }}\n          error={fieldError(touched.payment, paymentErrors.expiry) && true}\n        />\n        <Input\n          aria-label=\"Security code\"\n          placeholder=\"CVC\"\n          inputMode=\"numeric\"\n          autoComplete=\"off\"\n          value={card.cvc}\n          onChange={(cvc) => setCard((c) => ({ ...c, cvc: digits(cvc, 4) }))}\n          error={fieldError(touched.payment, paymentErrors.cvc) && true}\n        />\n      </div>\n      <p className=\"flex items-center gap-1.5 px-1 text-xs text-muted-foreground\">\n        <Lock className=\"size-3\" aria-hidden />\n        Demo fields — no card data is stored or sent.\n      </p>\n    </div>\n  );\n\n  const reviewRow = (label: string, value: ReactNode, step: number) => (\n    <ReviewRow label={label} step={step}>\n      {value}\n    </ReviewRow>\n  );\n\n  const reviewPanel = (\n    <div className=\"flex flex-col gap-2\">\n      {reviewRow(\n        \"Ship to\",\n        <>\n          {shipping.name}, {shipping.address}, {shipping.city} {shipping.zip}\n        </>,\n        0,\n      )}\n      {reviewRow(\n        \"Delivery\",\n        delivery === \"express\" ? \"Express · 1–2 days\" : \"Standard · 5–7 days\",\n        0,\n      )}\n      {reviewRow(\"Payment\", `Card ending ${digits(card.number, 16).slice(-4)}`, 1)}\n      <AnimatePresence initial={false}>\n        {orderError ? (\n          <motion.p\n            role=\"alert\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0 }}\n            className=\"rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive\"\n          >\n            {orderError}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n\n  const summary = (\n    <aside\n      aria-label=\"Order summary\"\n      className=\"flex flex-col gap-3 rounded-2xl bg-muted/50 p-3.5\"\n    >\n      <p className=\"text-sm font-semibold text-foreground\">Order summary</p>\n      <ul className=\"flex flex-col gap-2.5\">\n        {items.map((item, index) => (\n          <li key={item.id} className=\"flex items-center gap-2.5\">\n            {item.image ?? (\n              <span\n                aria-hidden\n                className={cn(\n                  \"size-9 shrink-0 rounded-xl opacity-80\",\n                  SWATCHES[index % SWATCHES.length],\n                )}\n              />\n            )}\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"truncate text-sm font-medium text-foreground\">\n                {item.name}\n                {item.variant ? (\n                  <span className=\"font-normal text-muted-foreground\"> · {item.variant}</span>\n                ) : null}\n              </p>\n              <div className=\"mt-0.5 flex items-center justify-between gap-2\">\n                {placed ? (\n                  <span className=\"text-xs text-muted-foreground\">Qty {item.quantity}</span>\n                ) : (\n                  <Quantity\n                    label={`${item.name} quantity`}\n                    value={item.quantity}\n                    onChange={(q) => setQuantity(item.id, q)}\n                  />\n                )}\n                <Money\n                  value={cents(item.price) * item.quantity}\n                  format={format}\n                  className=\"text-xs text-foreground\"\n                />\n              </div>\n            </div>\n          </li>\n        ))}\n      </ul>\n\n      <div className=\"h-px bg-border\" aria-hidden />\n\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {promo ? (\n          <motion.div\n            key=\"applied\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: reduce ? 1 : 0.9, transition: { duration: 0.12 } }}\n            transition={reduce ? { duration: 0.15 } : SPRING_SWAP}\n            className=\"flex items-center justify-between gap-2\"\n          >\n            <Badge\n              variant=\"success\"\n              icon={<Tag />}\n              onRemove={placed ? undefined : () => setPromo(null)}\n              removeLabel={`Remove code ${promo.code}`}\n            >\n              {promo.code.toUpperCase()}\n            </Badge>\n            <span className=\"truncate text-xs text-muted-foreground\">{promo.label}</span>\n          </motion.div>\n        ) : placed ? null : (\n          <motion.form\n            key=\"field\"\n            ref={promoRef}\n            noValidate\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            onSubmit={(event) => {\n              event.preventDefault();\n              void applyPromo();\n            }}\n            className=\"flex flex-col gap-1\"\n          >\n            <div className=\"flex items-center gap-2\">\n              <Input\n                aria-label=\"Promo code\"\n                placeholder=\"Promo code\"\n                autoComplete=\"off\"\n                value={promoInput}\n                onChange={(next) => {\n                  setPromoInput(next);\n                  setPromoError(undefined);\n                }}\n                error={Boolean(promoError)}\n                className=\"min-w-0 flex-1\"\n                classNames={{ field: \"h-9\" }}\n              />\n              <StatefulButton\n                type=\"submit\"\n                size=\"sm\"\n                variant=\"secondary\"\n                state={promoState}\n                loadingText=\"…\"\n                disabled={!promoInput.trim()}\n              >\n                Apply\n              </StatefulButton>\n            </div>\n            <AnimatePresence initial={false}>\n              {promoError ? (\n                <motion.p\n                  role=\"alert\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0 }}\n                  className=\"px-1 text-xs text-destructive\"\n                >\n                  {promoError}\n                </motion.p>\n              ) : null}\n            </AnimatePresence>\n          </motion.form>\n        )}\n      </AnimatePresence>\n\n      <dl className=\"flex flex-col gap-1.5 text-xs\">\n        <div className=\"flex justify-between text-muted-foreground\">\n          <dt>Subtotal</dt>\n          <dd>\n            <Money value={totals.subtotal} format={format} className=\"text-foreground\" />\n          </dd>\n        </div>\n        <AnimatePresence initial={false}>\n          {totals.discount > 0 ? (\n            <motion.div\n              key=\"discount\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, x: -8 }}\n              animate={{ opacity: 1, x: 0 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.22, ease: EASE_OUT }}\n              className=\"flex justify-between text-muted-foreground\"\n            >\n              <dt>Discount</dt>\n              <dd className=\"text-success\">\n                −<Money value={totals.discount} format={format} />\n              </dd>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n        <div className=\"flex justify-between text-muted-foreground\">\n          <dt>Shipping</dt>\n          <dd className=\"text-foreground\">\n            {totals.shipping === 0 ? \"Free\" : <Money value={totals.shipping} format={format} />}\n          </dd>\n        </div>\n        <div className=\"flex justify-between text-muted-foreground\">\n          <dt>Tax</dt>\n          <dd>\n            <Money value={totals.tax} format={format} className=\"text-foreground\" />\n          </dd>\n        </div>\n        <div className=\"mt-1 flex items-baseline justify-between border-t border-border pt-2.5\">\n          <dt className=\"text-sm font-medium text-foreground\">Total</dt>\n          <dd>\n            <Money\n              value={totals.total}\n              format={format}\n              className=\"text-base font-semibold text-foreground\"\n            />\n          </dd>\n        </div>\n      </dl>\n    </aside>\n  );\n\n  return (\n    <div className={cn(\"@container w-full\", className)}>\n      <div className=\"grid gap-4 rounded-3xl border border-border bg-card p-4 @xl:grid-cols-[minmax(0,1fr)_15rem]\">\n        <div className=\"min-w-0\">\n          <AnimatePresence mode=\"wait\" initial={false}>\n            {placed ? (\n              <motion.div\n                key=\"placed\"\n                role=\"status\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: 8 }}\n                animate={{ opacity: 1, scale: 1, y: 0 }}\n                transition={{ duration: 0.3, ease: EASE_OUT }}\n                className=\"flex h-full min-h-72 flex-col items-center justify-center gap-3 text-center\"\n              >\n                <motion.span\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  transition={reduce ? { duration: 0.2 } : { ...SPRING_SWAP, delay: 0.08 }}\n                  className=\"grid size-14 place-items-center rounded-full bg-success/15 text-success\"\n                >\n                  <svg viewBox=\"0 0 24 24\" className=\"size-7\" fill=\"none\" aria-hidden=\"true\">\n                    <motion.path\n                      d=\"M5 12.5l4.5 4.5L19 7.5\"\n                      stroke=\"currentColor\"\n                      strokeWidth={2.4}\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      initial={{ pathLength: reduce ? 1 : 0 }}\n                      animate={{ pathLength: 1 }}\n                      transition={{ duration: 0.4, delay: 0.2, ease: EASE_OUT }}\n                    />\n                  </svg>\n                </motion.span>\n                <h2 className=\"text-lg font-semibold tracking-tight text-foreground\">\n                  Order placed\n                </h2>\n                <p className=\"max-w-xs text-sm text-muted-foreground\">\n                  {placed.reference ? (\n                    <>\n                      Order <span className=\"font-medium text-foreground\">{placed.reference}</span>{\" \"}\n                      is confirmed.{\" \"}\n                    </>\n                  ) : null}\n                  A receipt is on its way to you, {shipping.name.split(\" \")[0]}.\n                </p>\n              </motion.div>\n            ) : (\n              <motion.div\n                key=\"steps\"\n                exit={{ opacity: 0, transition: { duration: 0.14 } }}\n              >\n                <Stepper steps={STEPS} canAdvance={canAdvance} onComplete={complete} className=\"gap-4\">\n                  <StepperIndicator showTitles />\n                  <StepperPanels>\n                    {shippingPanel}\n                    {paymentPanel}\n                    {reviewPanel}\n                  </StepperPanels>\n                  <div className=\"flex items-center justify-between\">\n                    <StepperPrevious size=\"md\" />\n                    <PlaceOrder label={`Pay ${format(totals.total)}`} />\n                  </div>\n                </Stepper>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n        {summary}\n      </div>\n    </div>\n  );\n}\n\nfunction ReviewRow({\n  label,\n  step,\n  children,\n}: {\n  label: string;\n  step: number;\n  children: ReactNode;\n}) {\n  const { goTo } = useStepper();\n  return (\n    <div className=\"flex items-start justify-between gap-3 rounded-2xl border border-border px-3 py-2.5\">\n      <div className=\"min-w-0\">\n        <p className=\"text-xs text-muted-foreground\">{label}</p>\n        <p className=\"text-sm text-foreground\">{children}</p>\n      </div>\n      <Button\n        type=\"button\"\n        size=\"sm\"\n        variant=\"ghost\"\n        aria-label={`Edit ${label.toLowerCase()}`}\n        onClick={() => void goTo(step)}\n      >\n        Edit\n      </Button>\n    </div>\n  );\n}\n"},{"path":"components/motion/badge.tsx","type":"registry:component","target":"@components/motion/badge.tsx","content":"\"use client\";\n\nimport { X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  PresenceContext,\n  useAnimate,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type BadgeVariant =\n  | \"default\"\n  | \"secondary\"\n  | \"outline\"\n  | \"destructive\"\n  | \"success\"\n  | \"warning\";\n\nexport type BadgeSize = \"sm\" | \"md\" | \"lg\";\n\n/** Soft tinted surface or a filled one. Only the status variants differ. */\nexport type BadgeTone = \"soft\" | \"solid\";\n\nconst BASE: Record<\"default\" | \"secondary\" | \"outline\", string> = {\n  default: \"border-transparent bg-primary text-primary-foreground\",\n  secondary: \"border-transparent bg-foreground/[0.07] text-foreground\",\n  outline: \"border-border-strong bg-transparent text-foreground\",\n};\n\n// Status tints mix the token toward --foreground/--background (as `alert`\n// does), so the text keeps its contrast in both themes and every design style.\nconst SOFT: Record<\"destructive\" | \"success\" | \"warning\", string> = {\n  destructive:\n    \"border-[color-mix(in_oklab,var(--destructive)_30%,transparent)] bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] text-[color-mix(in_oklab,var(--destructive)_78%,var(--foreground))]\",\n  success:\n    \"border-[color-mix(in_oklab,var(--success)_32%,transparent)] bg-[color-mix(in_oklab,var(--success)_11%,var(--background))] text-[color-mix(in_oklab,var(--success)_60%,var(--foreground))]\",\n  warning:\n    \"border-[color-mix(in_oklab,var(--warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--warning)_13%,var(--background))] text-[color-mix(in_oklab,var(--warning)_52%,var(--foreground))]\",\n};\n\n// Filled status badges. Red reads best with light text in both themes;\n// green and amber are light hues, so they always get dark text.\nconst SOLID: Record<\"destructive\" | \"success\" | \"warning\", string> = {\n  destructive:\n    \"border-transparent bg-destructive text-background dark:text-foreground\",\n  success:\n    \"border-transparent bg-success text-[color-mix(in_oklab,var(--success)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--success)_22%,var(--background))]\",\n  warning:\n    \"border-transparent bg-warning text-[color-mix(in_oklab,var(--warning)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--warning)_22%,var(--background))]\",\n};\n\nconst SIZE: Record<BadgeSize, string> = {\n  sm: \"h-5 gap-1 px-1.5 text-[11px] [&_svg]:size-3\",\n  md: \"h-6 gap-1.5 px-2 text-xs [&_svg]:size-3.5\",\n  lg: \"h-7 gap-1.5 px-2.5 text-[13px] [&_svg]:size-3.5\",\n};\n\n// Count badges are circles at one digit and pills beyond.\nconst COUNT_SIZE: Record<BadgeSize, string> = {\n  sm: \"h-4 min-w-4 px-1 text-[10px]\",\n  md: \"h-5 min-w-5 px-1.5 text-[11px]\",\n  lg: \"h-6 min-w-6 px-1.5 text-xs\",\n};\n\nfunction variantClass(variant: BadgeVariant, tone: BadgeTone) {\n  if (variant === \"default\" || variant === \"secondary\" || variant === \"outline\") {\n    return BASE[variant];\n  }\n  return tone === \"solid\" ? SOLID[variant] : SOFT[variant];\n}\n\nexport interface BadgeProps {\n  children?: ReactNode;\n  variant?: BadgeVariant;\n  /** Status variants only (destructive/success/warning). Default \"soft\". */\n  tone?: BadgeTone;\n  size?: BadgeSize;\n  /** Leading status dot in the text color; `\"pulse\"` adds a radiating ring. */\n  dot?: boolean | \"pulse\";\n  /** Leading icon (sized to the badge). Ignored when `dot` is set. */\n  icon?: ReactNode;\n  /**\n   * Makes the badge removable: an × button plays the exit, then calls this.\n   * Focus moves to the neighbouring chip's × when the removal came from it.\n   */\n  onRemove?: () => void;\n  /** Accessible name of the × button. Default `Remove <text>`. */\n  removeLabel?: string;\n  /**\n   * Count mode: renders a compact number that rolls up or down as it changes\n   * and bumps when it rises. Hidden at 0 unless `showZero`.\n   */\n  count?: number;\n  /** Counts above this read \"max+\". Default 99. */\n  max?: number;\n  showZero?: boolean;\n  /** Spoken label for count mode, e.g. `(n) => \\`${n} unread\\``. */\n  countLabel?: (count: number) => string;\n  className?: string;\n}\n\n/**\n * General-purpose badge: six variants, three sizes, an optional (pulsing) dot\n * or icon, removable chips with an exit animation, and a count mode with\n * rolling digits capped at \"99+\". For animated status transitions\n * (loading → success) use `AnimatedBadge` instead.\n */\nexport function Badge(props: BadgeProps) {\n  if (props.count !== undefined) return <CountBadge {...props} count={props.count} />;\n  return <LabelBadge {...props} />;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 Label badge                                */\n/* -------------------------------------------------------------------------- */\n\nfunction LabelBadge({\n  children,\n  variant = \"default\",\n  tone = \"soft\",\n  size = \"md\",\n  dot = false,\n  icon,\n  onRemove,\n  removeLabel,\n  className,\n}: BadgeProps) {\n  const reduce = useReducedMotion() ?? false;\n  // Inside an AnimatePresence the badge pops in when added; anywhere else it\n  // renders as-is so server HTML is visible before hydration.\n  const inPresence = useContext(PresenceContext) !== null;\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const [leaving, setLeaving] = useState(false);\n  const refocus = useRef<HTMLElement | null>(null);\n  const removable = Boolean(onRemove);\n\n  const hidden = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 };\n\n  const startRemove = (fromKeyboardFocus: boolean) => {\n    if (leaving) return;\n    if (fromKeyboardFocus) refocus.current = neighbourRemoveButton(rootRef.current);\n    setLeaving(true);\n  };\n\n  const finishRemove = () => {\n    if (!leaving) return;\n    const target = refocus.current;\n    refocus.current = null;\n    onRemove?.();\n    if (target) requestAnimationFrame(() => target.focus({ preventScroll: true }));\n  };\n\n  const text = typeof children === \"string\" ? children : undefined;\n\n  return (\n    <motion.span\n      ref={rootRef}\n      data-badge=\"\"\n      // Siblings glide into the gap a removed chip leaves (transform-only).\n      layout={removable && !reduce ? \"position\" : false}\n      initial={inPresence ? hidden : false}\n      animate={\n        leaving\n          ? { ...hidden, pointerEvents: \"none\" }\n          : { opacity: 1, scale: 1, pointerEvents: \"auto\" }\n      }\n      // Already faded out by the × — leave at once so siblings close the gap.\n      exit={{\n        ...hidden,\n        pointerEvents: \"none\",\n        transition: { duration: leaving ? 0 : 0.12, ease: EASE_OUT },\n      }}\n      transition={\n        leaving\n          ? { duration: 0.14, ease: EASE_OUT }\n          : reduce\n            ? { duration: 0.12 }\n            : { ...SPRING_PANEL, layout: SPRING_LAYOUT }\n      }\n      onAnimationComplete={finishRemove}\n      className={cn(\n        \"inline-flex shrink-0 select-none items-center whitespace-nowrap rounded-full border font-medium leading-none\",\n        SIZE[size],\n        variantClass(variant, tone),\n        removable && (size === \"sm\" ? \"pr-0.5\" : \"pr-1\"),\n        className,\n      )}\n    >\n      {dot ? (\n        <Dot pulse={dot === \"pulse\" && !reduce} />\n      ) : icon ? (\n        <span aria-hidden=\"true\" className=\"inline-flex shrink-0\">\n          {icon}\n        </span>\n      ) : null}\n      <span className=\"min-w-0 truncate\">{children}</span>\n      {removable ? (\n        <button\n          type=\"button\"\n          data-badge-remove=\"\"\n          aria-label={removeLabel ?? (text ? `Remove ${text}` : \"Remove\")}\n          onClick={(event) => startRemove(event.detail === 0)}\n          onKeyDown={(event: ReactKeyboardEvent<HTMLButtonElement>) => {\n            if (event.key === \"Backspace\" || event.key === \"Delete\") {\n              event.preventDefault();\n              startRemove(true);\n            }\n          }}\n          className={cn(\n            \"inline-flex shrink-0 items-center justify-center rounded-full opacity-70 outline-none transition-[opacity,background-color] hover:bg-current/15 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40\",\n            size === \"sm\" ? \"size-4\" : \"size-[18px]\",\n          )}\n        >\n          <X aria-hidden=\"true\" strokeWidth={2.4} className=\"size-3!\" />\n        </button>\n      ) : null}\n    </motion.span>\n  );\n}\n\n/** The × of the next chip in the row, else the previous one. */\nfunction neighbourRemoveButton(node: HTMLElement | null) {\n  if (!node) return null;\n  const pick = (el: Element | null) =>\n    el?.querySelector<HTMLElement>(\"[data-badge-remove]\") ?? null;\n  let next = node.nextElementSibling;\n  while (next && !pick(next)) next = next.nextElementSibling;\n  if (next) return pick(next);\n  let prev = node.previousElementSibling;\n  while (prev && !pick(prev)) prev = prev.previousElementSibling;\n  return prev ? pick(prev) : null;\n}\n\n// One pulse cycle of the dot's radiating ring, in seconds. Slow on purpose:\n// an ambient \"live\" signal, not feedback, so it sits well outside UI timing.\nconst PULSE_DURATION = 1.6;\n\nfunction Dot({ pulse }: { pulse: boolean }) {\n  return (\n    <span aria-hidden=\"true\" className=\"relative inline-flex size-1.5 shrink-0\">\n      {pulse ? (\n        <motion.span\n          className=\"absolute inset-0 rounded-full bg-current\"\n          initial={{ scale: 1, opacity: 0.55 }}\n          animate={{ scale: 2.6, opacity: 0 }}\n          transition={{\n            duration: PULSE_DURATION,\n            ease: EASE_OUT,\n            repeat: Number.POSITIVE_INFINITY,\n          }}\n        />\n      ) : null}\n      <span className=\"relative size-1.5 rounded-full bg-current\" />\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 Count badge                                */\n/* -------------------------------------------------------------------------- */\n\nfunction CountBadge({\n  count,\n  max = 99,\n  showZero = false,\n  countLabel,\n  variant = \"destructive\",\n  tone = \"solid\",\n  size = \"md\",\n  className,\n}: BadgeProps & { count: number }) {\n  const reduce = useReducedMotion() ?? false;\n  const [scope, animate] = useAnimate<HTMLSpanElement>();\n  const value = Math.max(0, Math.round(count));\n  const visible = value > 0 || showZero;\n  const text = value > max ? `${max}+` : String(value);\n\n  // Direction is derived during render (not in an effect) so the very render\n  // that swaps the digits already rolls them the right way.\n  const [seen, setSeen] = useState(value);\n  const [direction, setDirection] = useState<1 | -1>(1);\n  if (value !== seen) {\n    setSeen(value);\n    setDirection(value > seen ? 1 : -1);\n  }\n\n  // Rising counts bump the pill (not on the pop-in from zero, which has its\n  // own entrance).\n  const previous = useRef(value);\n  useEffect(() => {\n    const prev = previous.current;\n    previous.current = value;\n    if (value > prev && prev > 0 && !reduce && scope.current) {\n      animate(scope.current, { scale: [1, 1.18, 1] }, { duration: 0.32, ease: EASE_OUT });\n    }\n  }, [value, reduce, animate, scope]);\n\n  return (\n    <AnimatePresence initial={false}>\n      {visible ? (\n        <motion.span\n          key=\"count\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={\n            reduce\n              ? { opacity: 0, transition: { duration: 0.1 } }\n              : { opacity: 0, scale: 0.4, transition: { duration: 0.14, ease: EASE_OUT } }\n          }\n          transition={reduce ? { duration: 0.12 } : SPRING_PANEL}\n          className={cn(\"inline-flex shrink-0\", className)}\n        >\n          <span\n            ref={scope}\n            data-badge-count=\"\"\n            className={cn(\n              \"inline-flex items-center justify-center rounded-full border font-semibold leading-none tabular-nums\",\n              COUNT_SIZE[size],\n              variantClass(variant, tone),\n            )}\n          >\n            <span className=\"sr-only\">{countLabel ? countLabel(value) : text}</span>\n            <RollingCount text={text} direction={direction} reduce={reduce} />\n          </span>\n        </motion.span>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\nconst roll: Variants = {\n  enter: (dir: 1 | -1) => ({ y: dir > 0 ? \"100%\" : \"-100%\", opacity: 0 }),\n  center: { y: \"0%\", opacity: 1 },\n  exit: (dir: 1 | -1) => ({\n    y: dir > 0 ? \"-100%\" : \"100%\",\n    opacity: 0,\n    transition: { duration: 0.14, ease: EASE_OUT },\n  }),\n};\n\nconst fade: Variants = {\n  enter: { opacity: 0 },\n  center: { opacity: 1 },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\n/**\n * Direction-aware digit roll. `NumberTicker` spins each digit along a 0–9\n * strip, so 9 → 10 would roll the ones digit backwards; here every changed\n * glyph slides in from the direction of change instead.\n */\nfunction RollingCount({\n  text,\n  direction,\n  reduce,\n}: {\n  text: string;\n  direction: 1 | -1;\n  reduce: boolean;\n}) {\n  const chars = text.split(\"\");\n  // Keyed by place value from the right so a digit keeps its slot as the\n  // number grows on the left.\n  const glyphs = chars.map((char, i) => ({ char, place: chars.length - 1 - i }));\n  return (\n    <span aria-hidden=\"true\" className=\"inline-flex\">\n      {glyphs.map(({ char, place }) => (\n        <span\n          key={place}\n          className=\"relative inline-flex h-[1.2em] items-center justify-center overflow-hidden\"\n        >\n          <span className=\"invisible\">{char}</span>\n          <AnimatePresence initial={false} custom={direction}>\n            <motion.span\n              key={char}\n              custom={direction}\n              variants={reduce ? fade : roll}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n              className=\"absolute inset-0 flex items-center justify-center\"\n            >\n              {char}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\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/input.tsx","type":"registry:component","target":"@components/motion/input.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type InputHTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InputClassNames = {\n  root?: string;\n  label?: string;\n  field?: string;\n  input?: string;\n  leftIcon?: string;\n  rightIcon?: string;\n  successIcon?: string;\n  errorMessage?: string;\n};\n\nexport interface InputProps extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"value\" | \"defaultValue\" | \"onChange\"\n> {\n  label?: string;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  /** Truthy error triggers a shake, red border and (if a string) a message. */\n  error?: string | boolean;\n  success?: boolean;\n  leftIcon?: ReactNode;\n  rightIcon?: ReactNode;\n  className?: string;\n  classNames?: InputClassNames;\n}\n\nexport const Input = forwardRef<HTMLInputElement, InputProps>(function Input(\n  {\n    label,\n    value: valueProp,\n    defaultValue,\n    onChange,\n    onFocus,\n    onBlur,\n    error,\n    success,\n    leftIcon,\n    rightIcon,\n    className,\n    classNames,\n    disabled,\n    id: idProp,\n    type,\n    ...rest\n  },\n  ref,\n) {\n  const reactId = useId();\n  const id = idProp ?? reactId;\n  const reduce = useReducedMotion();\n\n  const controlled = valueProp !== undefined;\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const value = controlled ? (valueProp ?? \"\") : internal;\n\n  const [focused, setFocused] = useState(false);\n\n  const fieldRef = useRef<HTMLDivElement>(null);\n\n  const hasError = Boolean(error);\n  const errorMessage = typeof error === \"string\" ? error : null;\n\n  // Right edge shows the success check, otherwise the caller's right icon.\n  const rightSlot = success ? null : rightIcon;\n\n  // Shake the field when an error appears.\n  useEffect(() => {\n    if (!fieldRef.current || reduce || !hasError) return;\n    animate(\n      fieldRef.current,\n      { x: [0, -6, 6, -4, 4, -2, 0] },\n      { duration: 0.45 },\n    );\n  }, [hasError, reduce]);\n\n  const handleChange = (next: string) => {\n    if (!controlled) setInternal(next);\n    onChange?.(next);\n  };\n\n  return (\n    <div\n      className={cn(\"flex flex-col gap-1.5\", className, classNames?.root)}\n    >\n      {label ? (\n        <label\n          htmlFor={id}\n          className={cn(\n            \"px-1 text-sm font-medium text-foreground\",\n            classNames?.label,\n          )}\n        >\n          {label}\n        </label>\n      ) : null}\n\n      <div\n        ref={fieldRef}\n        data-state={\n          hasError\n            ? \"error\"\n            : success\n              ? \"success\"\n              : focused\n                ? \"focused\"\n                : \"idle\"\n        }\n        className={cn(\n          \"relative h-11 overflow-hidden rounded-full border transition-colors duration-200\",\n          \"border-border\",\n          focused && !hasError && \"border-foreground/40 ring-2 ring-ring/40\",\n          hasError && \"border-destructive ring-2 ring-destructive/25\",\n          disabled && \"opacity-60\",\n          classNames?.field,\n        )}\n      >\n        {leftIcon ? (\n          <span\n            className={cn(\n              \"pointer-events-none absolute left-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.leftIcon,\n            )}\n          >\n            {leftIcon}\n          </span>\n        ) : null}\n\n        <input\n          ref={ref}\n          id={id}\n          type={type}\n          value={value}\n          disabled={disabled}\n          aria-invalid={hasError || undefined}\n          aria-describedby={errorMessage ? `${id}-error` : undefined}\n          {...rest}\n          onChange={(e) => handleChange(e.target.value)}\n          onFocus={(event) => {\n            setFocused(true);\n            onFocus?.(event);\n          }}\n          onBlur={(event) => {\n            setFocused(false);\n            onBlur?.(event);\n          }}\n          className={cn(\n            \"peer h-full w-full bg-transparent text-base leading-6 text-foreground caret-foreground outline-none\",\n            \"placeholder:text-muted-foreground/60\",\n            leftIcon ? \"pl-10\" : \"pl-3.5\",\n            rightSlot || success ? \"pr-10\" : \"pr-3.5\",\n            disabled && \"cursor-not-allowed\",\n            classNames?.input,\n          )}\n        />\n\n        {success ? (\n          <motion.svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            className={cn(\n              \"absolute right-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-(--color-success)\",\n              classNames?.successIcon,\n            )}\n          >\n            <motion.path\n              d=\"M5 12.5l4.5 4.5L19 7.5\"\n              stroke=\"currentColor\"\n              strokeWidth={2.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: 0.35, ease: \"easeOut\" }}\n            />\n          </motion.svg>\n        ) : rightSlot ? (\n          <span\n            className={cn(\n              \"absolute right-0 top-0 flex h-full items-center text-muted-foreground [&_button]:grid [&_button]:size-11 [&_button]:place-items-center [&_svg]:h-4 [&_svg]:w-4\",\n              classNames?.rightIcon,\n            )}\n          >\n            {rightSlot}\n          </span>\n        ) : null}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {errorMessage ? (\n          <motion.p\n            id={`${id}-error`}\n            role=\"alert\"\n            initial={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n            }\n            transition={{ duration: 0.2 }}\n            className={cn(\n              \"px-1 text-xs text-destructive\",\n              classNames?.errorMessage,\n            )}\n          >\n            {errorMessage}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n});\n"},{"path":"components/motion/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/stepper.tsx","type":"registry:component","target":"@components/motion/stepper.tsx","content":"\"use client\";\n\nimport { Check } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useAnimationControls,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  Children,\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Button, type ButtonProps } from \"@/components/motion/button\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type StepperStep = {\n  id: string;\n  title: string;\n  description?: string;\n};\n\n/** Return false (or a promise of false) to keep the user on the current step. */\nexport type StepperGuard = (index: number) => boolean | Promise<boolean>;\n\nexport interface StepperProps {\n  steps: StepperStep[];\n  /** Controlled active step index. */\n  value?: number;\n  /** Uncontrolled initial step index. Default 0. */\n  defaultValue?: number;\n  onValueChange?: (index: number) => void;\n  /** Runs before leaving a step forward (Next, or jumping ahead). */\n  canAdvance?: StepperGuard;\n  /** Called when Next is pressed on the last step and its guard passes. */\n  onComplete?: () => void | Promise<void>;\n  /**\n   * Linear (default): the indicator only jumps back, or forward to steps\n   * already reached. Non-linear: any step, still guarded by `canAdvance`.\n   */\n  linear?: boolean;\n  children: ReactNode;\n  className?: string;\n}\n\ntype StepperContextValue = {\n  steps: StepperStep[];\n  value: number;\n  count: number;\n  direction: 1 | -1;\n  reached: number;\n  completed: ReadonlySet<number>;\n  errorStep: number | null;\n  errorTick: number;\n  pending: boolean;\n  linear: boolean;\n  isFirst: boolean;\n  isLast: boolean;\n  baseId: string;\n  next: () => Promise<boolean>;\n  previous: () => void;\n  goTo: (index: number) => Promise<boolean>;\n};\n\nconst StepperContext = createContext<StepperContextValue | null>(null);\n\n/** Step state and navigation for custom controls inside `<Stepper>`. */\nexport function useStepper() {\n  const ctx = useContext(StepperContext);\n  if (!ctx) throw new Error(\"useStepper must be used within <Stepper>\");\n  return ctx;\n}\n\nconst clamp = (n: number, max: number) => Math.min(Math.max(n, 0), max);\n\n/**\n * Multi-step flow: an indicator whose active ring glides between steps and\n * whose connectors fill as you advance, panels that slide in from the\n * direction of travel while the container morphs to each panel's height, and\n * a guard per step that shakes the indicator when it refuses. Controlled or\n * uncontrolled; reduced-motion safe.\n */\nexport function Stepper({\n  steps,\n  value: controlledValue,\n  defaultValue = 0,\n  onValueChange,\n  canAdvance,\n  onComplete,\n  linear = true,\n  children,\n  className,\n}: StepperProps) {\n  const count = steps.length;\n  const baseId = useId();\n  const [internalValue, setInternalValue] = useState(() =>\n    clamp(defaultValue, count - 1),\n  );\n  const value = clamp(controlledValue ?? internalValue, count - 1);\n\n  const [direction, setDirection] = useState<1 | -1>(1);\n  const [reached, setReached] = useState(value);\n  const [completed, setCompleted] = useState<ReadonlySet<number>>(\n    () => new Set(),\n  );\n  const [errorStep, setErrorStep] = useState<number | null>(null);\n  // Bumped on every refusal so the same step can shake twice in a row.\n  const [errorTick, setErrorTick] = useState(0);\n  const [pending, setPending] = useState(false);\n\n  // A controlled jump past `reached` (e.g. restoring a draft) still counts.\n  useEffect(() => setReached((r) => Math.max(r, value)), [value]);\n\n  const commit = useCallback(\n    (index: number) => {\n      setDirection(index > value ? 1 : -1);\n      setErrorStep(null);\n      if (controlledValue === undefined) setInternalValue(index);\n      onValueChange?.(index);\n    },\n    [value, controlledValue, onValueChange],\n  );\n\n  const guard = useCallback(\n    async (index: number) => {\n      if (!canAdvance) return true;\n      setPending(true);\n      try {\n        return await canAdvance(index);\n      } finally {\n        setPending(false);\n      }\n    },\n    [canAdvance],\n  );\n\n  const refuse = useCallback((index: number) => {\n    setErrorStep(index);\n    setErrorTick((t) => t + 1);\n  }, []);\n\n  const markDone = useCallback((from: number, to: number) => {\n    setCompleted((prev) => {\n      const next = new Set(prev);\n      for (let i = from; i < to; i++) next.add(i);\n      return next;\n    });\n  }, []);\n\n  const goTo = useCallback(\n    async (target: number) => {\n      const index = clamp(target, count - 1);\n      if (index === value || pending) return false;\n      if (index < value) {\n        commit(index);\n        return true;\n      }\n      // Linear: forward only to steps already reached, or the very next one.\n      if (linear && index > Math.max(reached, value + 1)) return false;\n      // Every step being skipped over must pass its guard, in order.\n      for (let i = value; i < index; i++) {\n        if (!(await guard(i))) {\n          refuse(i);\n          if (i !== value) commit(i);\n          return false;\n        }\n      }\n      markDone(value, index);\n      setReached((r) => Math.max(r, index));\n      commit(index);\n      return true;\n    },\n    [count, value, pending, linear, reached, guard, refuse, markDone, commit],\n  );\n\n  const next = useCallback(async () => {\n    if (pending) return false;\n    if (value < count - 1) return goTo(value + 1);\n    if (!(await guard(value))) {\n      refuse(value);\n      return false;\n    }\n    markDone(value, value + 1);\n    setErrorStep(null);\n    setPending(true);\n    try {\n      await onComplete?.();\n    } finally {\n      setPending(false);\n    }\n    return true;\n  }, [pending, value, count, goTo, guard, refuse, markDone, onComplete]);\n\n  const previous = useCallback(() => {\n    if (value > 0 && !pending) commit(value - 1);\n  }, [value, pending, commit]);\n\n  const ctx = useMemo<StepperContextValue>(\n    () => ({\n      steps,\n      value,\n      count,\n      direction,\n      reached,\n      completed,\n      errorStep,\n      errorTick,\n      pending,\n      linear,\n      isFirst: value === 0,\n      isLast: value === count - 1,\n      baseId,\n      next,\n      previous,\n      goTo,\n    }),\n    [\n      steps,\n      value,\n      count,\n      direction,\n      reached,\n      completed,\n      errorStep,\n      errorTick,\n      pending,\n      linear,\n      baseId,\n      next,\n      previous,\n      goTo,\n    ],\n  );\n\n  return (\n    <StepperContext.Provider value={ctx}>\n      <div className={cn(\"flex w-full flex-col gap-6\", className)}>\n        {children}\n      </div>\n    </StepperContext.Provider>\n  );\n}\n\n// ── indicator ──────────────────────────────────────────────────────────────\n\nexport interface StepperIndicatorProps {\n  /** Show each step's title under its marker. Default true. */\n  showTitles?: boolean;\n  className?: string;\n}\n\n/** Numbered markers joined by connectors that fill as steps complete. */\nexport function StepperIndicator({\n  showTitles = true,\n  className,\n}: StepperIndicatorProps) {\n  const {\n    steps,\n    value,\n    reached,\n    completed,\n    errorStep,\n    errorTick,\n    linear,\n    baseId,\n    goTo,\n  } = useStepper();\n  const reduce = useReducedMotion();\n\n  return (\n    <ol className={cn(\"flex w-full items-start\", className)}>\n      {steps.map((step, i) => {\n        const active = i === value;\n        const done = completed.has(i) && !active;\n        const error = errorStep === i;\n        const reachable = !linear || i <= reached;\n        const state = error\n          ? \"error\"\n          : done\n            ? \"completed\"\n            : active\n              ? \"current\"\n              : \"upcoming\";\n\n        return (\n          <li\n            key={step.id}\n            // Equal columns keep the markers centered and make each connector\n            // span exactly one column width, center to center.\n            className=\"relative flex min-w-0 flex-1 flex-col items-center gap-2\"\n          >\n            {i < steps.length - 1 && (\n              // Connector from this marker's center to the next one's.\n              <span\n                aria-hidden\n                className=\"absolute top-4 left-[calc(50%+1.25rem)] right-[calc(-50%+1.25rem)] h-0.5 -translate-y-1/2 overflow-hidden rounded-full bg-border\"\n              >\n                <motion.span\n                  className=\"absolute inset-0 origin-left bg-primary\"\n                  initial={false}\n                  animate={{ scaleX: completed.has(i) ? 1 : 0 }}\n                  transition={\n                    reduce\n                      ? { duration: 0 }\n                      : { duration: 0.36, ease: EASE_OUT }\n                  }\n                />\n              </span>\n            )}\n            <StepMarker\n              index={i}\n              state={state}\n              shakeKey={error ? errorTick : 0}\n              disabled={!reachable || active}\n              label={`Step ${i + 1} of ${steps.length}: ${step.title}${\n                done ? \", completed\" : error ? \", needs attention\" : \"\"\n              }`}\n              current={active}\n              controls={`${baseId}-panel`}\n              onSelect={() => goTo(i)}\n              layoutGroup={baseId}\n            />\n            {showTitles && (\n              <span\n                aria-hidden\n                className={cn(\n                  \"max-w-28 truncate px-1 text-center text-xs transition-colors\",\n                  active || done ? \"text-foreground\" : \"text-muted-foreground\",\n                  error && \"text-destructive\",\n                )}\n              >\n                {step.title}\n              </span>\n            )}\n          </li>\n        );\n      })}\n    </ol>\n  );\n}\n\nfunction StepMarker({\n  index,\n  state,\n  shakeKey,\n  disabled,\n  label,\n  current,\n  controls,\n  onSelect,\n  layoutGroup,\n}: {\n  index: number;\n  state: \"upcoming\" | \"current\" | \"completed\" | \"error\";\n  shakeKey: number;\n  disabled: boolean;\n  label: string;\n  current: boolean;\n  controls: string;\n  onSelect: () => void;\n  layoutGroup: string;\n}) {\n  const reduce = useReducedMotion();\n  const shake = useAnimationControls();\n\n  useEffect(() => {\n    if (!shakeKey || reduce) return;\n    shake.start({\n      x: [0, -5, 5, -3, 3, 0],\n      transition: { duration: 0.4, ease: EASE_OUT },\n    });\n  }, [shakeKey, reduce, shake]);\n\n  return (\n    <motion.button\n      type=\"button\"\n      animate={shake}\n      disabled={disabled}\n      aria-label={label}\n      aria-current={current ? \"step\" : undefined}\n      aria-controls={current ? controls : undefined}\n      onClick={onSelect}\n      className={cn(\n        \"relative isolate flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-medium tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default\",\n        state === \"completed\" &&\n          \"border-primary bg-primary text-primary-foreground\",\n        state === \"current\" && \"border-transparent text-foreground\",\n        state === \"upcoming\" && \"border-border bg-card text-muted-foreground\",\n        state === \"error\" &&\n          \"border-destructive bg-destructive/10 text-destructive\",\n        !disabled && state !== \"current\" && \"hover:border-primary/60\",\n      )}\n    >\n      {state === \"current\" && (\n        <motion.span\n          aria-hidden\n          // The ring glides from marker to marker as the active step changes.\n          layoutId={`${layoutGroup}-ring`}\n          transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n          className=\"absolute -inset-px -z-10 rounded-full border-2 border-primary bg-card\"\n        />\n      )}\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {state === \"completed\" ? (\n          <motion.span\n            key=\"check\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={SPRING_SWAP}\n          >\n            <Check className=\"size-4\" strokeWidth={2.5} />\n          </motion.span>\n        ) : (\n          <motion.span\n            key=\"number\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={SPRING_SWAP}\n          >\n            {index + 1}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </motion.button>\n  );\n}\n\n// ── panels ─────────────────────────────────────────────────────────────────\n\nexport interface StepperPanelsProps {\n  /** One child per step, in step order. */\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * Shows the active step's child. Panels slide in from the direction of\n * travel and the container morphs to each panel's height. Focus moves to the\n * new panel after navigation so keyboard and screen-reader users land on it.\n */\nexport function StepperPanels({ children, className }: StepperPanelsProps) {\n  const { value, direction, steps, baseId } = useStepper();\n  const reduce = useReducedMotion();\n  const panels = Children.toArray(children);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const shownStep = useRef(value);\n\n  useEffect(() => {\n    // Only move focus in response to navigation, never on first render.\n    if (shownStep.current === value) return;\n    shownStep.current = value;\n    // The exiting panel is still mounted mid-transition, so look the entering\n    // one up by its step instead of trusting a shared ref.\n    containerRef.current\n      ?.querySelector<HTMLElement>(`[data-step=\"${value}\"]`)\n      ?.focus({ preventScroll: true });\n  }, [value]);\n\n  const offset = reduce ? 0 : 24;\n\n  return (\n    <motion.div\n      layout\n      ref={containerRef}\n      transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n      className={cn(\"relative overflow-hidden\", className)}\n    >\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n        <motion.div\n          key={value}\n          data-step={value}\n          id={`${baseId}-panel`}\n          role=\"group\"\n          aria-label={`Step ${value + 1}: ${steps[value]?.title ?? \"\"}`}\n          tabIndex={-1}\n          layout=\"position\"\n          custom={direction}\n          variants={{\n            enter: (dir: number) => ({\n              opacity: 0,\n              x: dir * offset,\n              filter: reduce ? \"blur(0px)\" : \"blur(4px)\",\n            }),\n            center: {\n              opacity: 1,\n              x: 0,\n              filter: \"blur(0px)\",\n              transition: { duration: 0.26, ease: EASE_OUT },\n            },\n            exit: (dir: number) => ({\n              opacity: 0,\n              x: dir * -offset,\n              filter: reduce ? \"blur(0px)\" : \"blur(4px)\",\n              transition: { duration: 0.16, ease: EASE_OUT },\n            }),\n          }}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          className=\"outline-none\"\n        >\n          {panels[value]}\n        </motion.div>\n      </AnimatePresence>\n    </motion.div>\n  );\n}\n\n// ── controls ───────────────────────────────────────────────────────────────\n\nexport interface StepperPreviousProps extends Omit<ButtonProps, \"onClick\"> {\n  children?: ReactNode;\n}\n\n/** Goes back one step. Hidden from layout on the first step. */\nexport function StepperPrevious({\n  children = \"Back\",\n  className,\n  variant = \"ghost\",\n  ...props\n}: StepperPreviousProps) {\n  const { isFirst, pending, previous } = useStepper();\n  return (\n    <Button\n      type=\"button\"\n      variant={variant}\n      disabled={isFirst || pending}\n      onClick={previous}\n      className={cn(isFirst && \"invisible\", className)}\n      {...props}\n    >\n      {children}\n    </Button>\n  );\n}\n\nexport interface StepperNextProps extends Omit<ButtonProps, \"onClick\"> {\n  children?: ReactNode;\n  /** Label on the last step. Default \"Finish\". */\n  finishLabel?: ReactNode;\n}\n\n/** Advances through the step guard; on the last step it runs `onComplete`. */\nexport function StepperNext({\n  children = \"Continue\",\n  finishLabel = \"Finish\",\n  ...props\n}: StepperNextProps) {\n  const { isLast, pending, next } = useStepper();\n  return (\n    <Button\n      type=\"button\"\n      disabled={pending}\n      aria-busy={pending || undefined}\n      onClick={() => void next()}\n      {...props}\n    >\n      {isLast ? finishLabel : children}\n    </Button>\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"}]}