{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"settings-page","type":"registry:block","title":"Settings Page","description":"Account settings with a side nav whose pill glides between Profile, Notifications, Appearance and Billing, sections built from inline edits, switches, selects, toggle groups and usage bars, a draft that slides up a sticky unsaved-changes bar with Reset and an async Save that shakes on failure, and a danger zone behind a type-to-confirm dialog.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/settings-page.tsx","type":"registry:component","target":"@components/motion/settings-page.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/settings-page\n\nimport {\n  Bell,\n  CreditCard,\n  type LucideIcon,\n  Monitor,\n  Moon,\n  Palette,\n  Sun,\n  Trash2,\n  User,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport { type ReactNode, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Badge } from \"@/components/motion/badge\";\nimport { Button, StatefulButton } from \"@/components/motion/button\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogConfirmInput,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/motion/dialog\";\nimport { InlineEdit } from \"@/components/motion/inline-edit\";\nimport { Progress } from \"@/components/motion/progress\";\nimport { ScrollArea } from \"@/components/motion/scroll-area\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/motion/select\";\nimport { Switch } from \"@/components/motion/switch\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/motion/toggle-group\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SettingsSection = \"profile\" | \"notifications\" | \"appearance\" | \"billing\";\n\nexport type SettingsValues = {\n  name: string;\n  username: string;\n  email: string;\n  bio: string;\n  notifyMentions: boolean;\n  notifyComments: boolean;\n  notifyProduct: boolean;\n  digest: \"off\" | \"daily\" | \"weekly\";\n  theme: \"system\" | \"light\" | \"dark\";\n  density: \"comfortable\" | \"compact\";\n  reduceMotion: boolean;\n};\n\nexport type SettingsBilling = {\n  plan: string;\n  price: string;\n  renewal: string;\n  seats: { used: number; total: number };\n  storage: { used: number; total: number; unit: string };\n  /** Display-only label such as \"Card ending 4242\". Never real card data. */\n  paymentMethod?: string;\n};\n\nexport interface SettingsPageProps {\n  defaultValues?: Partial<SettingsValues>;\n  /** Persist the draft. Reject to keep the bar open with an error and a shake. */\n  onSave?: (values: SettingsValues) => void | Promise<void>;\n  /** Fires after the type-to-confirm dialog. */\n  onDeleteAccount?: () => void | Promise<void>;\n  onManageBilling?: () => void;\n  /** Controlled active section. */\n  section?: SettingsSection;\n  defaultSection?: SettingsSection;\n  onSectionChange?: (section: SettingsSection) => void;\n  billing?: SettingsBilling;\n  className?: string;\n}\n\nconst DEFAULT_VALUES: SettingsValues = {\n  name: \"Ada Lovelace\",\n  username: \"ada\",\n  email: \"ada@example.com\",\n  bio: \"Writing the first programs for engines that don't exist yet.\",\n  notifyMentions: true,\n  notifyComments: true,\n  notifyProduct: false,\n  digest: \"weekly\",\n  theme: \"system\",\n  density: \"comfortable\",\n  reduceMotion: false,\n};\n\nconst DEFAULT_BILLING: SettingsBilling = {\n  plan: \"Pro\",\n  price: \"$12 / month\",\n  renewal: \"Renews Nov 14, 2026\",\n  seats: { used: 3, total: 5 },\n  storage: { used: 18, total: 50, unit: \"GB\" },\n  paymentMethod: \"Card ending 4242\",\n};\n\nconst SECTIONS: { id: SettingsSection; label: string; icon: LucideIcon }[] = [\n  { id: \"profile\", label: \"Profile\", icon: User },\n  { id: \"notifications\", label: \"Notifications\", icon: Bell },\n  { id: \"appearance\", label: \"Appearance\", icon: Palette },\n  { id: \"billing\", label: \"Billing\", icon: CreditCard },\n];\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nconst USERNAME_PATTERN = /^[a-z0-9_]{3,20}$/;\nconst SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };\nconst SAVED_HOLD_MS = 1100;\n\nfunction SettingRow({\n  title,\n  description,\n  children,\n  stacked,\n}: {\n  title: string;\n  description?: string;\n  children: ReactNode;\n  stacked?: boolean;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex gap-3 border-b border-border py-3.5 last:border-b-0\",\n        stacked ? \"flex-col\" : \"items-center justify-between\",\n      )}\n    >\n      <div className=\"min-w-0\">\n        <p className=\"text-sm font-medium text-foreground\">{title}</p>\n        {description ? (\n          <p className=\"text-xs text-muted-foreground\">{description}</p>\n        ) : null}\n      </div>\n      {children}\n    </div>\n  );\n}\n\nfunction SectionHeading({ title, description }: { title: string; description: string }) {\n  return (\n    <div className=\"mb-2 flex flex-col gap-0.5\">\n      <h3 className=\"text-base font-semibold tracking-tight text-foreground\">{title}</h3>\n      <p className=\"text-sm text-muted-foreground\">{description}</p>\n    </div>\n  );\n}\n\n/**\n * Account settings block: a side nav whose active pill glides between\n * Profile / Notifications / Appearance / Billing, sections built from\n * `InlineEdit`, `Switch`, `Select`, `ToggleGroup` and `Progress`, a draft that\n * raises a sticky \"unsaved changes\" bar (Save via `StatefulButton`, Reset)\n * and a danger zone behind a type-to-confirm `AlertDialog`.\n */\nexport function SettingsPage({\n  defaultValues,\n  onSave,\n  onDeleteAccount,\n  onManageBilling,\n  section: sectionProp,\n  defaultSection = \"profile\",\n  onSectionChange,\n  billing = DEFAULT_BILLING,\n  className,\n}: SettingsPageProps) {\n  const reduce = useReducedMotion();\n  const navId = useId();\n\n  const [internalSection, setInternalSection] = useState(defaultSection);\n  const section = sectionProp ?? internalSection;\n  const selectSection = (next: SettingsSection) => {\n    if (sectionProp === undefined) setInternalSection(next);\n    onSectionChange?.(next);\n  };\n\n  const [saved, setSaved] = useState<SettingsValues>(() => ({\n    ...DEFAULT_VALUES,\n    ...defaultValues,\n  }));\n  const [draft, setDraft] = useState<SettingsValues>(saved);\n  const [saveState, setSaveState] = useState<\"idle\" | \"loading\" | \"success\" | \"error\">(\n    \"idle\",\n  );\n  const barRef = useRef<HTMLDivElement>(null);\n\n  const changed = useMemo(\n    () => (Object.keys(draft) as (keyof SettingsValues)[]).filter((k) => draft[k] !== saved[k]),\n    [draft, saved],\n  );\n  const dirty = changed.length > 0;\n  const showBar = dirty || saveState === \"success\";\n\n  useEffect(() => {\n    if (saveState !== \"success\") return;\n    const id = window.setTimeout(() => setSaveState(\"idle\"), SAVED_HOLD_MS);\n    return () => window.clearTimeout(id);\n  }, [saveState]);\n\n  const set = <K extends keyof SettingsValues>(key: K, value: SettingsValues[K]) => {\n    setDraft((prev) => ({ ...prev, [key]: value }));\n    if (saveState === \"error\") setSaveState(\"idle\");\n  };\n\n  const save = async () => {\n    setSaveState(\"loading\");\n    try {\n      await onSave?.(draft);\n      setSaved(draft);\n      setSaveState(\"success\");\n    } catch {\n      setSaveState(\"error\");\n      if (!reduce && barRef.current) {\n        animate(barRef.current, SHAKE, { duration: 0.42, ease: EASE_OUT });\n      }\n    }\n  };\n\n  const reset = () => {\n    setDraft(saved);\n    setSaveState(\"idle\");\n  };\n\n  const fade = reduce\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }\n    : {\n        initial: { opacity: 0, y: 8 },\n        animate: { opacity: 1, y: 0 },\n        exit: { opacity: 0, y: -4 },\n      };\n\n  let content: ReactNode;\n  switch (section) {\n    case \"profile\":\n      content = (\n        <>\n          <SectionHeading title=\"Profile\" description=\"How you appear to your team.\" />\n          <div className=\"flex items-center gap-3 py-3\">\n            <span\n              aria-hidden\n              className=\"grid size-12 shrink-0 place-items-center rounded-full bg-foreground text-lg font-semibold text-background\"\n            >\n              {draft.name.trim().slice(0, 1).toUpperCase() || \"?\"}\n            </span>\n            <div className=\"min-w-0\">\n              <p className=\"truncate text-sm font-medium text-foreground\">{draft.name}</p>\n              <p className=\"truncate text-xs text-muted-foreground\">@{draft.username}</p>\n            </div>\n          </div>\n          <SettingRow title=\"Display name\" stacked>\n            <InlineEdit\n              label=\"Display name\"\n              value={draft.name}\n              maxLength={48}\n              validate={(next) => (next.trim() ? null : \"Name can't be empty.\")}\n              onSave={(next) => set(\"name\", next.trim())}\n            />\n          </SettingRow>\n          <SettingRow title=\"Username\" description=\"Lowercase letters, numbers, underscores.\" stacked>\n            <InlineEdit\n              label=\"Username\"\n              value={draft.username}\n              maxLength={20}\n              validate={(next) =>\n                USERNAME_PATTERN.test(next) ? null : \"Use 3–20 of a–z, 0–9 or _.\"\n              }\n              onSave={(next) => set(\"username\", next)}\n            />\n          </SettingRow>\n          <SettingRow title=\"Email\" stacked>\n            <InlineEdit\n              label=\"Email\"\n              value={draft.email}\n              validate={(next) =>\n                EMAIL_PATTERN.test(next) ? null : \"That doesn't look like an email address.\"\n              }\n              onSave={(next) => set(\"email\", next.trim())}\n            />\n          </SettingRow>\n          <SettingRow title=\"Bio\" stacked>\n            <InlineEdit\n              label=\"Bio\"\n              multiline\n              maxLength={160}\n              placeholder=\"Tell people a little about yourself\"\n              value={draft.bio}\n              onSave={(next) => set(\"bio\", next)}\n            />\n          </SettingRow>\n\n          <div className=\"mt-5 rounded-2xl border border-destructive/30 p-4\">\n            <p className=\"text-sm font-semibold text-destructive\">Danger zone</p>\n            <div className=\"mt-1 flex flex-wrap items-center justify-between gap-3\">\n              <p className=\"max-w-64 text-xs text-muted-foreground\">\n                Permanently delete your account and everything in it.\n              </p>\n              <AlertDialog confirmText={saved.username}>\n                <AlertDialogTrigger\n                  variant=\"outline\"\n                  size=\"sm\"\n                  className=\"border-destructive/40 text-destructive hover:bg-destructive/10\"\n                >\n                  <Trash2 className=\"size-3.5\" aria-hidden />\n                  Delete account\n                </AlertDialogTrigger>\n                <AlertDialogContent>\n                  <AlertDialogHeader>\n                    <AlertDialogTitle>Delete your account?</AlertDialogTitle>\n                    <AlertDialogDescription>\n                      This removes your profile, projects and billing history. It\n                      can&apos;t be undone.\n                    </AlertDialogDescription>\n                  </AlertDialogHeader>\n                  <AlertDialogConfirmInput />\n                  <AlertDialogFooter>\n                    <AlertDialogCancel />\n                    <AlertDialogAction onClick={() => void onDeleteAccount?.()}>\n                      Delete account\n                    </AlertDialogAction>\n                  </AlertDialogFooter>\n                </AlertDialogContent>\n              </AlertDialog>\n            </div>\n          </div>\n        </>\n      );\n      break;\n    case \"notifications\":\n      content = (\n        <>\n          <SectionHeading\n            title=\"Notifications\"\n            description=\"Choose what reaches your inbox.\"\n          />\n          <SettingRow title=\"Mentions\" description=\"When someone @mentions you.\">\n            <Switch\n              ariaLabel=\"Mentions\"\n              checked={draft.notifyMentions}\n              onCheckedChange={(next) => set(\"notifyMentions\", next)}\n            />\n          </SettingRow>\n          <SettingRow title=\"Comments\" description=\"Replies on threads you follow.\">\n            <Switch\n              ariaLabel=\"Comments\"\n              checked={draft.notifyComments}\n              onCheckedChange={(next) => set(\"notifyComments\", next)}\n            />\n          </SettingRow>\n          <SettingRow title=\"Product updates\" description=\"New features, at most monthly.\">\n            <Switch\n              ariaLabel=\"Product updates\"\n              checked={draft.notifyProduct}\n              onCheckedChange={(next) => set(\"notifyProduct\", next)}\n            />\n          </SettingRow>\n          <SettingRow title=\"Email digest\" description=\"A summary of activity you missed.\">\n            <Select\n              value={draft.digest}\n              onValueChange={(next) => set(\"digest\", next as SettingsValues[\"digest\"])}\n              className=\"w-32 shrink-0\"\n            >\n              <SelectTrigger>\n                <SelectValue placeholder=\"Digest\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"off\">Off</SelectItem>\n                <SelectItem value=\"daily\">Daily</SelectItem>\n                <SelectItem value=\"weekly\">Weekly</SelectItem>\n              </SelectContent>\n            </Select>\n          </SettingRow>\n        </>\n      );\n      break;\n    case \"appearance\":\n      content = (\n        <>\n          <SectionHeading title=\"Appearance\" description=\"Make the workspace yours.\" />\n          <SettingRow title=\"Theme\" description=\"Follow the system or pick one.\" stacked>\n            <ToggleGroup\n              type=\"single\"\n              required\n              variant=\"outline\"\n              aria-label=\"Theme\"\n              value={draft.theme}\n              onValueChange={(next) => set(\"theme\", next as SettingsValues[\"theme\"])}\n              className=\"w-fit\"\n            >\n              <ToggleGroupItem value=\"system\">\n                <Monitor aria-hidden />\n                System\n              </ToggleGroupItem>\n              <ToggleGroupItem value=\"light\">\n                <Sun aria-hidden />\n                Light\n              </ToggleGroupItem>\n              <ToggleGroupItem value=\"dark\">\n                <Moon aria-hidden />\n                Dark\n              </ToggleGroupItem>\n            </ToggleGroup>\n          </SettingRow>\n          <SettingRow title=\"Density\" description=\"Row height in lists and tables.\" stacked>\n            <ToggleGroup\n              type=\"single\"\n              required\n              variant=\"outline\"\n              aria-label=\"Density\"\n              value={draft.density}\n              onValueChange={(next) => set(\"density\", next as SettingsValues[\"density\"])}\n              className=\"w-fit\"\n            >\n              <ToggleGroupItem value=\"comfortable\">Comfortable</ToggleGroupItem>\n              <ToggleGroupItem value=\"compact\">Compact</ToggleGroupItem>\n            </ToggleGroup>\n          </SettingRow>\n          <SettingRow title=\"Reduce motion\" description=\"Fade instead of slide.\">\n            <Switch\n              ariaLabel=\"Reduce motion\"\n              checked={draft.reduceMotion}\n              onCheckedChange={(next) => set(\"reduceMotion\", next)}\n            />\n          </SettingRow>\n        </>\n      );\n      break;\n    case \"billing\":\n      content = (\n        <>\n          <SectionHeading title=\"Billing\" description=\"Your plan, usage and payment method.\" />\n          <div className=\"mt-2 flex items-center justify-between gap-3 rounded-2xl border border-border bg-muted/40 p-4\">\n            <div>\n              <div className=\"flex items-center gap-2\">\n                <p className=\"text-sm font-semibold text-foreground\">{billing.plan} plan</p>\n                <Badge variant=\"success\" size=\"sm\" dot>\n                  Active\n                </Badge>\n              </div>\n              <p className=\"text-xs text-muted-foreground\">\n                {billing.price} · {billing.renewal}\n              </p>\n            </div>\n            <Button size=\"sm\" variant=\"secondary\" onClick={onManageBilling}>\n              Manage\n            </Button>\n          </div>\n          <div className=\"flex flex-col gap-4 py-4\">\n            <Progress\n              size=\"sm\"\n              label=\"Seats\"\n              value={billing.seats.used}\n              max={billing.seats.total}\n              getValueText={() => `${billing.seats.used} of ${billing.seats.total}`}\n              trackClassName=\"bg-foreground/10\"\n            />\n            <Progress\n              size=\"sm\"\n              label={`Storage · ${billing.storage.used} of ${billing.storage.total} ${billing.storage.unit}`}\n              value={billing.storage.used}\n              max={billing.storage.total}\n              trackClassName=\"bg-foreground/10\"\n            />\n          </div>\n          {billing.paymentMethod ? (\n            <SettingRow title=\"Payment method\" description={billing.paymentMethod}>\n              <Button size=\"sm\" variant=\"ghost\" onClick={onManageBilling}>\n                Update\n              </Button>\n            </SettingRow>\n          ) : null}\n        </>\n      );\n      break;\n  }\n\n  return (\n    <div className={cn(\"@container h-[28rem] w-full\", className)}>\n      <div className=\"flex h-full flex-col overflow-hidden rounded-3xl border border-border bg-card @lg:flex-row\">\n        <LayoutGroup id={navId}>\n          <nav\n            aria-label=\"Settings sections\"\n            className=\"flex shrink-0 gap-1 overflow-x-auto border-b border-border p-2 [scrollbar-width:none] @lg:w-44 @lg:flex-col @lg:border-r @lg:border-b-0 @lg:p-3 [&::-webkit-scrollbar]:hidden\"\n          >\n            {SECTIONS.map((item) => {\n              const active = item.id === section;\n              const Icon = item.icon;\n              return (\n                <button\n                  key={item.id}\n                  type=\"button\"\n                  aria-current={active ? \"page\" : undefined}\n                  onClick={() => selectSection(item.id)}\n                  className={cn(\n                    \"relative flex shrink-0 items-center gap-2.5 rounded-xl px-3 py-2 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n                    active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                  )}\n                >\n                  {active ? (\n                    <motion.span\n                      layoutId=\"settings-nav-pill\"\n                      transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                      className=\"absolute inset-0 rounded-xl bg-muted\"\n                      aria-hidden\n                    />\n                  ) : null}\n                  <Icon className=\"relative size-4\" aria-hidden />\n                  <span className=\"relative font-medium\">{item.label}</span>\n                </button>\n              );\n            })}\n          </nav>\n        </LayoutGroup>\n\n        <div className=\"relative min-h-0 min-w-0 flex-1\">\n          <ScrollArea className=\"h-full\" aria-label={`${section} settings`}>\n            <div className=\"px-5 pt-5 pb-24\">\n              <AnimatePresence mode=\"wait\" initial={false}>\n                <motion.div\n                  key={section}\n                  {...fade}\n                  transition={{ duration: 0.2, ease: EASE_OUT }}\n                >\n                  {content}\n                </motion.div>\n              </AnimatePresence>\n            </div>\n          </ScrollArea>\n\n          <AnimatePresence>\n            {showBar ? (\n              <motion.div\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 72 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={\n                  reduce\n                    ? { opacity: 0 }\n                    : { opacity: 0, y: 72, transition: { duration: 0.18, ease: EASE_OUT } }\n                }\n                transition={reduce ? { duration: 0.15 } : SPRING_PANEL}\n                className=\"absolute inset-x-3 bottom-3 z-20\"\n              >\n                <div\n                  ref={barRef}\n                  role=\"status\"\n                  className=\"flex items-center justify-between gap-3 rounded-2xl border border-border-strong bg-card py-2 pr-2 pl-4 shadow-lg\"\n                >\n                  <p className=\"min-w-0 truncate text-sm text-foreground\">\n                    {saveState === \"success\"\n                      ? \"All changes saved\"\n                      : saveState === \"error\"\n                        ? \"Couldn't save. Try again.\"\n                        : `${changed.length} unsaved ${changed.length === 1 ? \"change\" : \"changes\"}`}\n                  </p>\n                  <div className=\"flex shrink-0 items-center gap-1.5\">\n                    <Button\n                      size=\"sm\"\n                      variant=\"ghost\"\n                      onClick={reset}\n                      disabled={!dirty || saveState === \"loading\"}\n                    >\n                      Reset\n                    </Button>\n                    <StatefulButton\n                      size=\"sm\"\n                      state={saveState}\n                      loadingText=\"Saving\"\n                      successText=\"Saved\"\n                      errorText=\"Retry\"\n                      onClick={() => void save()}\n                      disabled={!dirty && saveState !== \"success\"}\n                    >\n                      Save\n                    </StatefulButton>\n                  </div>\n                </div>\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n        </div>\n      </div>\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/dialog.tsx","type":"registry:component","target":"@components/motion/dialog.tsx","content":"\"use client\";\n\nimport { X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ComponentPropsWithoutRef,\n  type MouseEvent,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n  cloneElement,\n  createContext,\n  forwardRef,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Button, type ButtonProps } from \"@/components/motion/button\";\nimport { Input, type InputProps } from \"@/components/motion/input\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                               Shared plumbing                              */\n/* -------------------------------------------------------------------------- */\n\nconst FOCUSABLE_SELECTOR = [\n  \"a[href]\",\n  \"button:not([disabled])\",\n  \"input:not([disabled]):not([type='hidden'])\",\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  \"[contenteditable='true']\",\n  '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\nfunction getFocusable(root: HTMLElement | null) {\n  if (!root) return [];\n  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(\n    (el) => el.tabIndex >= 0 && !el.closest(\"[inert]\") && el.getClientRects().length > 0,\n  );\n}\n\n// Open dialogs, innermost last. Only the top one answers Escape and Tab, so an\n// AlertDialog opened from inside a Dialog doesn't close both at once.\nconst dialogStack: string[] = [];\n\n// Reference-counted body scroll lock. The scrollbar's width is put back as\n// padding so the page behind doesn't jump sideways when the bar disappears.\nlet lockCount = 0;\nlet savedBodyStyle: { overflow: string; paddingRight: string } | null = null;\n\nfunction lockScroll() {\n  lockCount += 1;\n  if (lockCount > 1) return;\n  const body = document.body;\n  const scrollbar = window.innerWidth - document.documentElement.clientWidth;\n  savedBodyStyle = {\n    overflow: body.style.overflow,\n    paddingRight: body.style.paddingRight,\n  };\n  if (scrollbar > 0) {\n    const current = Number.parseFloat(getComputedStyle(body).paddingRight) || 0;\n    body.style.paddingRight = `${current + scrollbar}px`;\n  }\n  body.style.overflow = \"hidden\";\n}\n\nfunction unlockScroll() {\n  lockCount = Math.max(0, lockCount - 1);\n  if (lockCount > 0 || !savedBodyStyle) return;\n  document.body.style.overflow = savedBodyStyle.overflow;\n  document.body.style.paddingRight = savedBodyStyle.paddingRight;\n  savedBodyStyle = null;\n}\n\nconst SM_QUERY = \"(max-width: 639.98px)\";\n\nfunction subscribeSmall(callback: () => void) {\n  const mql = window.matchMedia(SM_QUERY);\n  mql.addEventListener(\"change\", callback);\n  return () => mql.removeEventListener(\"change\", callback);\n}\n\n/** True below Tailwind's `sm` breakpoint. Always false on the server. */\nfunction useBelowSm() {\n  return useSyncExternalStore(\n    subscribeSmall,\n    () => window.matchMedia(SM_QUERY).matches,\n    () => false,\n  );\n}\n\nconst SHAKE_X = [0, -7, 7, -5, 5, -2, 0];\n\n/* -------------------------------------------------------------------------- */\n/*                                   Context                                  */\n/* -------------------------------------------------------------------------- */\n\ntype DialogContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  alert: boolean;\n  contentId: string;\n  titleId: string;\n  descriptionId: string;\n  hasDescription: boolean;\n  setHasDescription: (value: boolean) => void;\n  /** Type-to-confirm (AlertDialog only). */\n  confirmText?: string;\n  confirmValue: string;\n  setConfirmValue: (value: string) => void;\n  confirmValid: boolean;\n  confirmAttempted: boolean;\n  setConfirmAttempted: (value: boolean) => void;\n  confirmFieldRef: RefObject<HTMLDivElement | null>;\n  confirmInputRef: RefObject<HTMLInputElement | null>;\n};\n\nconst DialogContext = createContext<DialogContextValue | null>(null);\n\nfunction useDialogContext(component: string) {\n  const context = useContext(DialogContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <Dialog> or <AlertDialog>`);\n  }\n  return context;\n}\n\nexport interface DialogProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nfunction DialogRoot({\n  children,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  alert,\n  confirmText,\n}: DialogProps & { alert: boolean; confirmText?: string }) {\n  const id = useId();\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = openProp !== undefined;\n  const open = controlled ? openProp : internalOpen;\n  const [hasDescription, setHasDescription] = useState(false);\n  const [confirmValue, setConfirmValue] = useState(\"\");\n  const [confirmAttempted, setConfirmAttempted] = useState(false);\n  const confirmFieldRef = useRef<HTMLDivElement>(null);\n  const confirmInputRef = useRef<HTMLInputElement>(null);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n\n  // A fresh confirmation every time the dialog opens.\n  useEffect(() => {\n    if (!open) return;\n    setConfirmValue(\"\");\n    setConfirmAttempted(false);\n  }, [open]);\n\n  const confirmValid =\n    confirmText === undefined || confirmValue.trim() === confirmText;\n\n  const value = useMemo<DialogContextValue>(\n    () => ({\n      open,\n      setOpen,\n      alert,\n      contentId: `${id}-content`,\n      titleId: `${id}-title`,\n      descriptionId: `${id}-description`,\n      hasDescription,\n      setHasDescription,\n      confirmText,\n      confirmValue,\n      setConfirmValue,\n      confirmValid,\n      confirmAttempted,\n      setConfirmAttempted,\n      confirmFieldRef,\n      confirmInputRef,\n    }),\n    [\n      open,\n      setOpen,\n      alert,\n      id,\n      hasDescription,\n      confirmText,\n      confirmValue,\n      confirmValid,\n      confirmAttempted,\n    ],\n  );\n\n  return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;\n}\n\n/**\n * Modal dialog root. Holds open state (controlled via `open`/`onOpenChange`\n * or uncontrolled via `defaultOpen`) for its Trigger, Content and Close parts.\n */\nexport function Dialog(props: DialogProps) {\n  return <DialogRoot {...props} alert={false} />;\n}\n\nexport interface AlertDialogProps extends DialogProps {\n  /**\n   * Require the user to type this exact text (e.g. the project name) into\n   * `AlertDialogConfirmInput` before `AlertDialogAction` goes through.\n   */\n  confirmText?: string;\n}\n\n/**\n * Interrupting confirmation. Renders `role=\"alertdialog\"`, never closes on an\n * outside click, and focuses `AlertDialogCancel` first so a stray Enter can't\n * confirm a destructive action.\n */\nexport function AlertDialog({ confirmText, ...props }: AlertDialogProps) {\n  return <DialogRoot {...props} alert confirmText={confirmText} />;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                               Trigger & Close                              */\n/* -------------------------------------------------------------------------- */\n\ntype ClonableChild = ReactElement<Record<string, unknown>>;\n\nfunction composeClick(\n  child: ClonableChild,\n  action: () => void,\n): (event: MouseEvent<HTMLElement>) => void {\n  const childOnClick = child.props.onClick as\n    | ((event: MouseEvent<HTMLElement>) => void)\n    | undefined;\n  return (event) => {\n    childOnClick?.(event);\n    if (!event.defaultPrevented) action();\n  };\n}\n\nexport interface DialogTriggerProps extends ButtonProps {\n  /** Merge behaviour onto the single child element instead of rendering a `Button`. */\n  asChild?: boolean;\n}\n\n/** Opens the dialog. Renders a library `Button`, or wraps its child with `asChild`. */\nexport const DialogTrigger = forwardRef<HTMLButtonElement, DialogTriggerProps>(\n  function DialogTrigger({ asChild, children, onClick, ...rest }, ref) {\n    const context = useDialogContext(\"DialogTrigger\");\n    const aria = {\n      \"aria-haspopup\": \"dialog\" as const,\n      \"aria-expanded\": context.open,\n      \"aria-controls\": context.open ? context.contentId : undefined,\n    };\n\n    if (asChild && isValidElement(children)) {\n      const child = children as ClonableChild;\n      return cloneElement(child, {\n        ...aria,\n        onClick: composeClick(child, () => context.setOpen(true)),\n      });\n    }\n\n    return (\n      <Button\n        ref={ref}\n        {...rest}\n        {...aria}\n        onClick={(event) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) context.setOpen(true);\n        }}\n      >\n        {children}\n      </Button>\n    );\n  },\n);\n\nexport interface DialogCloseProps extends ButtonProps {\n  /** Merge behaviour onto the single child element instead of rendering a `Button`. */\n  asChild?: boolean;\n}\n\n/** Closes the dialog. Renders a library `Button` (outline by default), or wraps its child. */\nexport const DialogClose = forwardRef<HTMLButtonElement, DialogCloseProps>(\n  function DialogClose(\n    { asChild, children, onClick, variant = \"outline\", ...rest },\n    ref,\n  ) {\n    const context = useDialogContext(\"DialogClose\");\n\n    if (asChild && isValidElement(children)) {\n      const child = children as ClonableChild;\n      return cloneElement(child, {\n        onClick: composeClick(child, () => context.setOpen(false)),\n      });\n    }\n\n    return (\n      <Button\n        ref={ref}\n        variant={variant}\n        {...rest}\n        onClick={(event) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) context.setOpen(false);\n        }}\n      >\n        {children}\n      </Button>\n    );\n  },\n);\n\n/* -------------------------------------------------------------------------- */\n/*                                   Content                                  */\n/* -------------------------------------------------------------------------- */\n\nexport interface DialogContentProps {\n  children: ReactNode;\n  className?: string;\n  /** Class for the dimmed, blurred backdrop. */\n  overlayClassName?: string;\n  /** Show the corner close (X) button. Default true for Dialog, false for AlertDialog. */\n  showCloseButton?: boolean;\n  closeLabel?: string;\n  /**\n   * Close when the backdrop is pressed. Default true for Dialog; AlertDialog\n   * ignores it and never closes on an outside press.\n   */\n  closeOnOutsideClick?: boolean;\n  /** Element to focus on open. Falls back to `[data-dialog-autofocus]`, then the first focusable. */\n  initialFocus?: RefObject<HTMLElement | null>;\n  /** Become a bottom-anchored sheet below the `sm` breakpoint. Default true. */\n  sheetOnMobile?: boolean;\n  /** Accessible name, when the content has no `DialogTitle`. */\n  \"aria-label\"?: string;\n}\n\nconst ENTER = { duration: 0.22, ease: EASE_OUT } as const;\nconst EXIT = { duration: 0.14, ease: EASE_OUT } as const;\n\nfunction DialogContentInner({\n  children,\n  className,\n  overlayClassName,\n  showCloseButton,\n  closeLabel = \"Close\",\n  closeOnOutsideClick = true,\n  initialFocus,\n  sheetOnMobile = true,\n  \"aria-label\": ariaLabel,\n}: DialogContentProps) {\n  const context = useDialogContext(\"DialogContent\");\n  const reduce = useReducedMotion() ?? false;\n  const isPresent = useIsPresent();\n  const belowSm = useBelowSm();\n  const sheet = sheetOnMobile && belowSm;\n  const panelRef = useRef<HTMLDivElement>(null);\n  const stackId = useId();\n  const { setOpen } = context;\n  // Read through a ref so a new onOpenChange identity (inline handlers) does\n  // not re-run the focus/scroll-lock effect and yank focus mid-typing.\n  const setOpenRef = useRef(setOpen);\n  const initialFocusRef = useRef(initialFocus);\n  useEffect(() => {\n    setOpenRef.current = setOpen;\n    initialFocusRef.current = initialFocus;\n  });\n  const dismissOutside = closeOnOutsideClick && !context.alert;\n  const withClose = showCloseButton ?? !context.alert;\n\n  // Scroll lock, stack registration, initial focus, focus trap, Escape and\n  // focus return. Runs while this panel is mounted *and* present, so the\n  // exit animation already behaves like a closed dialog.\n  useEffect(() => {\n    if (!isPresent) return;\n    const returnTo =\n      document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    dialogStack.push(stackId);\n    lockScroll();\n\n    const frame = requestAnimationFrame(() => {\n      const panel = panelRef.current;\n      if (!panel) return;\n      const target =\n        initialFocusRef.current?.current ??\n        panel.querySelector<HTMLElement>(\"[data-dialog-autofocus]\") ??\n        getFocusable(panel).find((el) => !el.hasAttribute(\"data-dialog-close-x\")) ??\n        panel;\n      target.focus({ preventScroll: true });\n    });\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (dialogStack[dialogStack.length - 1] !== stackId) return;\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        setOpenRef.current(false);\n        return;\n      }\n      if (event.key !== \"Tab\") return;\n      const panel = panelRef.current;\n      const focusable = getFocusable(panel);\n      if (focusable.length === 0) {\n        event.preventDefault();\n        panel?.focus();\n        return;\n      }\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      const active = document.activeElement;\n      const outside = !panel?.contains(active);\n      if (event.shiftKey && (active === first || active === panel || outside)) {\n        event.preventDefault();\n        last.focus();\n      } else if (!event.shiftKey && (active === last || outside)) {\n        event.preventDefault();\n        first.focus();\n      }\n    };\n\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"keydown\", onKeyDown);\n      const index = dialogStack.lastIndexOf(stackId);\n      if (index !== -1) dialogStack.splice(index, 1);\n      unlockScroll();\n      if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });\n    };\n  }, [isPresent, stackId]);\n\n  const panelInitial = reduce\n    ? { opacity: 0 }\n    : sheet\n      ? { opacity: 0, y: \"100%\" }\n      : { opacity: 0, scale: 0.96 };\n  const panelExit = reduce\n    ? { opacity: 0, transition: EXIT }\n    : sheet\n      ? { opacity: 0, y: \"100%\", transition: { duration: 0.2, ease: EASE_OUT } }\n      : { opacity: 0, scale: 0.97, transition: EXIT };\n\n  return (\n    <div\n      className=\"pointer-events-none fixed inset-0 z-[100]\"\n      style={{ pointerEvents: isPresent ? undefined : \"none\" }}\n    >\n      <motion.button\n        type=\"button\"\n        aria-label={closeLabel}\n        tabIndex={-1}\n        disabled={!dismissOutside}\n        onClick={() => setOpen(false)}\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0, transition: EXIT }}\n        transition={ENTER}\n        className={cn(\n          \"pointer-events-auto absolute inset-0 h-full w-full cursor-default bg-black/35 backdrop-blur-sm disabled:cursor-default dark:bg-black/55\",\n          overlayClassName,\n        )}\n      />\n\n      <div\n        className={cn(\n          \"pointer-events-none absolute inset-0 flex justify-center\",\n          sheet ? \"items-end\" : \"items-center p-4\",\n        )}\n      >\n        <motion.div\n          ref={panelRef}\n          id={context.contentId}\n          role={context.alert ? \"alertdialog\" : \"dialog\"}\n          aria-modal=\"true\"\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabel ? undefined : context.titleId}\n          aria-describedby={context.hasDescription ? context.descriptionId : undefined}\n          tabIndex={-1}\n          initial={panelInitial}\n          animate={{ opacity: 1, scale: 1, y: 0 }}\n          exit={panelExit}\n          transition={reduce ? ENTER : sheet ? { duration: 0.32, ease: EASE_OUT } : ENTER}\n          style={{ pointerEvents: isPresent ? \"auto\" : \"none\" }}\n          className={cn(\n            \"relative flex w-full flex-col gap-5 overflow-y-auto border-border bg-background p-6 text-foreground shadow-2xl outline-none\",\n            \"[scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\",\n            sheet\n              ? \"max-h-[88dvh] rounded-t-3xl border-t pb-[max(1.5rem,env(safe-area-inset-bottom))]\"\n              : \"max-h-[calc(100dvh-2rem)] max-w-md rounded-3xl border\",\n            className,\n          )}\n        >\n          {sheet ? (\n            <span\n              aria-hidden=\"true\"\n              className=\"mx-auto -mt-2 -mb-1 h-1 w-10 shrink-0 rounded-full bg-foreground/15\"\n            />\n          ) : null}\n          {children}\n          {withClose ? (\n            <button\n              type=\"button\"\n              aria-label={closeLabel}\n              data-dialog-close-x=\"\"\n              onClick={() => setOpen(false)}\n              className=\"absolute top-4 right-4 inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              <X className=\"size-4\" aria-hidden=\"true\" />\n            </button>\n          ) : null}\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n\n/**\n * The modal surface. Portals to `document.body`, fades a blurred backdrop in,\n * scales the panel from 0.96 (fade only under reduced motion), traps focus,\n * closes on Escape and returns focus to whatever opened it.\n */\nexport function DialogContent(props: DialogContentProps) {\n  const context = useDialogContext(\"DialogContent\");\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  if (!mounted) return null;\n  return createPortal(\n    <AnimatePresence>\n      {context.open ? <DialogContentInner key=\"dialog\" {...props} /> : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                Text & layout                               */\n/* -------------------------------------------------------------------------- */\n\nexport function DialogHeader({\n  className,\n  ...props\n}: ComponentPropsWithoutRef<\"div\">) {\n  return (\n    <div\n      className={cn(\"flex flex-col gap-1.5 pr-8 text-left\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function DialogFooter({\n  className,\n  ...props\n}: ComponentPropsWithoutRef<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function DialogTitle({\n  className,\n  ...props\n}: Omit<ComponentPropsWithoutRef<\"h2\">, \"id\">) {\n  const context = useDialogContext(\"DialogTitle\");\n  return (\n    <h2\n      id={context.titleId}\n      className={cn(\"text-lg font-semibold tracking-tight\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function DialogDescription({\n  className,\n  ...props\n}: Omit<ComponentPropsWithoutRef<\"p\">, \"id\">) {\n  const context = useDialogContext(\"DialogDescription\");\n  const { setHasDescription } = context;\n  useEffect(() => {\n    setHasDescription(true);\n    return () => setHasDescription(false);\n  }, [setHasDescription]);\n  return (\n    <p\n      id={context.descriptionId}\n      className={cn(\"text-sm leading-relaxed text-muted-foreground\", className)}\n      {...props}\n    />\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              AlertDialog parts                             */\n/* -------------------------------------------------------------------------- */\n\nexport const AlertDialogTrigger = DialogTrigger;\nexport type AlertDialogTriggerProps = DialogTriggerProps;\nexport const AlertDialogHeader = DialogHeader;\nexport const AlertDialogFooter = DialogFooter;\nexport const AlertDialogTitle = DialogTitle;\nexport const AlertDialogDescription = DialogDescription;\n\nexport type AlertDialogContentProps = Omit<\n  DialogContentProps,\n  \"closeOnOutsideClick\"\n>;\n\nexport function AlertDialogContent(props: AlertDialogContentProps) {\n  return <DialogContent {...props} />;\n}\n\nexport interface AlertDialogCancelProps extends ButtonProps {}\n\n/** The safe choice. Receives initial focus when the AlertDialog opens. */\nexport const AlertDialogCancel = forwardRef<HTMLButtonElement, AlertDialogCancelProps>(\n  function AlertDialogCancel({ children = \"Cancel\", ...props }, ref) {\n    return (\n      <DialogClose ref={ref} data-dialog-autofocus=\"\" {...props}>\n        {children}\n      </DialogClose>\n    );\n  },\n);\n\nexport interface AlertDialogActionProps extends ButtonProps {\n  /** `destructive` (default) paints the button in the destructive token. */\n  tone?: \"destructive\" | \"default\";\n}\n\n/**\n * The confirming action. Closes the dialog after `onClick` unless the handler\n * calls `event.preventDefault()`. With `confirmText` set on the AlertDialog it\n * stays `aria-disabled` until the text matches; pressing it early shakes the\n * confirm field and moves focus there.\n */\nexport const AlertDialogAction = forwardRef<HTMLButtonElement, AlertDialogActionProps>(\n  function AlertDialogAction(\n    { tone = \"destructive\", className, onClick, children, ...props },\n    ref,\n  ) {\n    const context = useDialogContext(\"AlertDialogAction\");\n    const reduce = useReducedMotion();\n    const blocked = !context.confirmValid;\n\n    return (\n      <Button\n        ref={ref}\n        variant=\"primary\"\n        aria-disabled={blocked || undefined}\n        data-alert-dialog-action=\"\"\n        {...props}\n        className={cn(\n          tone === \"destructive\" &&\n            \"bg-destructive text-white hover:bg-destructive/90\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          blocked && \"opacity-55\",\n          blocked &&\n            (tone === \"destructive\" ? \"hover:bg-destructive\" : \"hover:bg-primary\"),\n          className,\n        )}\n        onClick={(event) => {\n          if (blocked) {\n            event.preventDefault();\n            // The field's own error shake plays the first time the message\n            // appears; later attempts shake it here instead.\n            const errorShowing = context.confirmAttempted;\n            context.setConfirmAttempted(true);\n            const field = context.confirmFieldRef.current;\n            if (field && errorShowing && !reduce) {\n              animate(field, { x: SHAKE_X }, { duration: 0.4 });\n            }\n            context.confirmInputRef.current?.focus();\n            return;\n          }\n          onClick?.(event);\n          if (!event.defaultPrevented) context.setOpen(false);\n        }}\n      >\n        {children}\n      </Button>\n    );\n  },\n);\n\nexport interface AlertDialogConfirmInputProps\n  extends Omit<InputProps, \"value\" | \"defaultValue\" | \"onChange\" | \"error\"> {\n  /** Message shown when confirming with a mismatch. */\n  mismatchMessage?: string;\n}\n\n/**\n * Type-to-confirm field for an `AlertDialog` with `confirmText`. Enter submits\n * through `AlertDialogAction`'s rules.\n */\nexport function AlertDialogConfirmInput({\n  label,\n  mismatchMessage = \"That doesn't match. Check the spelling and try again.\",\n  className,\n  onKeyDown,\n  ...props\n}: AlertDialogConfirmInputProps) {\n  const context = useDialogContext(\"AlertDialogConfirmInput\");\n  const target = context.confirmText ?? \"\";\n  const showError = context.confirmAttempted && !context.confirmValid;\n\n  return (\n    <div ref={context.confirmFieldRef} className={className}>\n      <Input\n        ref={context.confirmInputRef}\n        label={label ?? `Type \"${target}\" to confirm`}\n        autoComplete=\"off\"\n        autoCapitalize=\"off\"\n        spellCheck={false}\n        {...props}\n        value={context.confirmValue}\n        onChange={context.setConfirmValue}\n        error={showError ? mismatchMessage : undefined}\n        success={target !== \"\" && context.confirmValid}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n          if (event.defaultPrevented || event.key !== \"Enter\") return;\n          event.preventDefault();\n          const panel = event.currentTarget.closest<HTMLElement>(\"[role='alertdialog']\");\n          panel?.querySelector<HTMLButtonElement>(\"[data-alert-dialog-action]\")?.click();\n        }}\n      />\n    </div>\n  );\n}\n"},{"path":"components/motion/inline-edit.tsx","type":"registry:component","target":"@components/motion/inline-edit.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n} from \"react\";\nimport { Loader } from \"@/components/motion/loader\";\nimport { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type InlineEditClassNames = {\n  root?: string;\n  display?: string;\n  input?: string;\n  placeholder?: string;\n  errorMessage?: string;\n};\n\nexport interface InlineEditProps {\n  /** Accessible name of the field (\"Name\", \"Bio\"); also used in the edit hint. */\n  label: string;\n  value?: string;\n  defaultValue?: string;\n  /**\n   * Commit handler. Return a promise to show a pending spinner; the shown value\n   * rolls back (with a shake and the rejection message) if it rejects.\n   * Controlled callers update `value` here.\n   */\n  onSave?: (next: string) => unknown;\n  onCancel?: () => void;\n  onEditingChange?: (editing: boolean) => void;\n  /** Return an error message to block the save and keep the editor open. */\n  validate?: (next: string) => string | null | undefined | false;\n  /** Edit in an auto-growing textarea; Enter saves, Shift+Enter adds a line. */\n  multiline?: boolean;\n  placeholder?: string;\n  maxLength?: number;\n  /** Select the whole text when the editor opens (default) or put the caret at the end. */\n  selectOnEdit?: boolean;\n  disabled?: boolean;\n  className?: string;\n  classNames?: InlineEditClassNames;\n}\n\ntype Status = \"idle\" | \"pending\" | \"saved\";\n\nconst SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };\nconst SAVED_FLASH_MS = 1400;\n\nfunction messageOf(reason: unknown) {\n  if (reason instanceof Error && reason.message) return reason.message;\n  if (typeof reason === \"string\" && reason) return reason;\n  return \"Couldn't save. Try again.\";\n}\n\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n  return (\n    typeof value === \"object\" &&\n    value !== null &&\n    typeof (value as PromiseLike<unknown>).then === \"function\"\n  );\n}\n\nexport function InlineEdit({\n  label,\n  value: valueProp,\n  defaultValue,\n  onSave,\n  onCancel,\n  onEditingChange,\n  validate,\n  multiline = false,\n  placeholder = \"Empty\",\n  maxLength,\n  selectOnEdit = true,\n  disabled,\n  className,\n  classNames,\n}: InlineEditProps) {\n  const id = useId();\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 [editing, setEditing] = useState(false);\n  const [draft, setDraft] = useState(value);\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [pendingValue, setPendingValue] = useState(\"\");\n  const [error, setError] = useState<string | null>(null);\n\n  // Refs gate commit/cancel so the blur fired while the editor unmounts can't\n  // save a second time.\n  const editingRef = useRef(false);\n  const restoreFocusRef = useRef(false);\n  const saveIdRef = useRef(0);\n  const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const mountedRef = useRef(true);\n\n  const boxRef = useRef<HTMLDivElement>(null);\n  const displayRef = useRef<HTMLButtonElement>(null);\n  const fieldRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null);\n\n  useEffect(() => {\n    mountedRef.current = true;\n    return () => {\n      mountedRef.current = false;\n      if (flashTimer.current) clearTimeout(flashTimer.current);\n    };\n  }, []);\n\n  const shake = useCallback(() => {\n    if (!boxRef.current || reduce) return;\n    animate(boxRef.current, SHAKE, { duration: 0.45 });\n  }, [reduce]);\n\n  const setEditingState = (next: boolean) => {\n    editingRef.current = next;\n    setEditing(next);\n    onEditingChange?.(next);\n  };\n\n  // Focus the editor as it opens; return focus to the text after a\n  // keyboard commit or cancel (not after blur — that would steal focus).\n  useEffect(() => {\n    if (editing) {\n      const field = fieldRef.current;\n      if (!field) return;\n      field.focus();\n      if (selectOnEdit) field.select();\n      else field.setSelectionRange(field.value.length, field.value.length);\n    } else if (restoreFocusRef.current) {\n      restoreFocusRef.current = false;\n      displayRef.current?.focus();\n    }\n  }, [editing, selectOnEdit]);\n\n  const startEditing = () => {\n    if (disabled || editingRef.current || status === \"pending\") return;\n    if (flashTimer.current) clearTimeout(flashTimer.current);\n    setStatus(\"idle\");\n    setDraft(value);\n    setError(null);\n    setEditingState(true);\n  };\n\n  const cancel = () => {\n    if (!editingRef.current) return;\n    restoreFocusRef.current = true;\n    setDraft(value);\n    setError(null);\n    setEditingState(false);\n    onCancel?.();\n  };\n\n  const flashSaved = () => {\n    setStatus(\"saved\");\n    if (flashTimer.current) clearTimeout(flashTimer.current);\n    flashTimer.current = setTimeout(() => {\n      if (mountedRef.current) setStatus(\"idle\");\n    }, SAVED_FLASH_MS);\n  };\n\n  const commit = (via: \"key\" | \"blur\") => {\n    if (!editingRef.current) return;\n    const next = draft;\n    if (next === value) {\n      if (via === \"key\") restoreFocusRef.current = true;\n      setError(null);\n      setEditingState(false);\n      return;\n    }\n    const invalid = validate?.(next);\n    if (invalid) {\n      setError(invalid);\n      shake();\n      return;\n    }\n\n    if (via === \"key\") restoreFocusRef.current = true;\n    setError(null);\n    setEditingState(false);\n\n    const result = onSave?.(next);\n    if (!isThenable(result)) {\n      if (!controlled) setInternal(next);\n      flashSaved();\n      return;\n    }\n\n    const saveId = ++saveIdRef.current;\n    setPendingValue(next);\n    setStatus(\"pending\");\n    Promise.resolve(result).then(\n      () => {\n        if (!mountedRef.current || saveId !== saveIdRef.current) return;\n        if (!controlled) setInternal(next);\n        flashSaved();\n      },\n      (reason: unknown) => {\n        if (!mountedRef.current || saveId !== saveIdRef.current) return;\n        setStatus(\"idle\");\n        setError(messageOf(reason));\n        shake();\n      },\n    );\n  };\n\n  const onFieldKeyDown = (\n    event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,\n  ) => {\n    if (event.nativeEvent.isComposing) return;\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      cancel();\n    } else if (event.key === \"Enter\" && !(multiline && event.shiftKey)) {\n      event.preventDefault();\n      commit(\"key\");\n    }\n  };\n\n  const shown = status === \"pending\" ? pendingValue : value;\n  const errorId = `${id}-error`;\n  // Display and editor share this box model exactly, so swapping them never\n  // moves the text. The -mx-2 hangs the padding outside so text aligns with\n  // surrounding content.\n  const textBox = cn(\n    \"w-full rounded-lg px-2 py-1 pr-8 text-left\",\n    multiline ? \"whitespace-pre-wrap break-words\" : \"\",\n  );\n  const iconSlot = cn(\n    \"pointer-events-none absolute right-2 z-10 flex size-4 items-center justify-center\",\n    multiline ? \"top-[calc(0.25rem+0.5lh)] -translate-y-1/2\" : \"top-1/2 -translate-y-1/2\",\n  );\n\n  const motionIn = reduce\n    ? { opacity: 1 }\n    : { opacity: 1, scale: 1 };\n  const motionOut = reduce\n    ? { opacity: 0 }\n    : { opacity: 0, scale: 0.6 };\n\n  return (\n    <div\n      data-state={editing ? \"editing\" : status}\n      className={cn(\"relative -mx-2\", className, classNames?.root)}\n    >\n      <div ref={boxRef} className=\"group relative\">\n        {/* Field surface: fades and settles in under the text while editing. */}\n        <motion.span\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute inset-0 rounded-lg border bg-background ring-2\",\n            error\n              ? \"border-destructive ring-destructive/25\"\n              : \"border-foreground/30 ring-ring/40\",\n          )}\n          initial={false}\n          animate={\n            editing\n              ? motionIn\n              : reduce\n                ? { opacity: 0 }\n                : { opacity: 0, scale: 0.98 }\n          }\n          transition={{ duration: editing ? 0.2 : 0.12, ease: EASE_OUT }}\n        />\n\n        {editing ? (\n          multiline ? (\n            <div className=\"relative grid\">\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  textBox,\n                  \"invisible [grid-area:1/1]\",\n                  classNames?.input,\n                )}\n              >\n                {`${draft} `}\n              </span>\n              <textarea\n                ref={fieldRef}\n                aria-label={label}\n                aria-invalid={error ? true : undefined}\n                aria-describedby={error ? errorId : undefined}\n                rows={1}\n                value={draft}\n                maxLength={maxLength}\n                placeholder={placeholder}\n                onChange={(e) => {\n                  setDraft(e.target.value);\n                  if (error) setError(null);\n                }}\n                onKeyDown={onFieldKeyDown}\n                onBlur={() => commit(\"blur\")}\n                className={cn(\n                  textBox,\n                  \"relative resize-none overflow-hidden bg-transparent text-foreground caret-foreground outline-none [grid-area:1/1] placeholder:text-muted-foreground/60\",\n                  classNames?.input,\n                )}\n              />\n            </div>\n          ) : (\n            <input\n              ref={fieldRef}\n              aria-label={label}\n              aria-invalid={error ? true : undefined}\n              aria-describedby={error ? errorId : undefined}\n              value={draft}\n              maxLength={maxLength}\n              placeholder={placeholder}\n              onChange={(e) => {\n                setDraft(e.target.value);\n                if (error) setError(null);\n              }}\n              onKeyDown={onFieldKeyDown}\n              onBlur={() => commit(\"blur\")}\n              className={cn(\n                textBox,\n                \"relative block bg-transparent text-foreground caret-foreground outline-none placeholder:text-muted-foreground/60\",\n                classNames?.input,\n              )}\n            />\n          )\n        ) : (\n          <button\n            ref={displayRef}\n            type=\"button\"\n            disabled={disabled}\n            aria-describedby={error ? errorId : undefined}\n            onClick={startEditing}\n            onKeyDown={(event) => {\n              if (event.key === \"F2\") {\n                event.preventDefault();\n                startEditing();\n              }\n            }}\n            className={cn(\n              textBox,\n              \"relative block cursor-text transition-[background-color,opacity] duration-150\",\n              \"outline-none hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring/50\",\n              !multiline && \"truncate\",\n              status === \"pending\" && \"opacity-70\",\n              disabled && \"cursor-not-allowed hover:bg-transparent\",\n              classNames?.display,\n            )}\n          >\n            {shown ? (\n              shown\n            ) : (\n              <span\n                className={cn(\n                  \"text-muted-foreground/70 italic\",\n                  classNames?.placeholder,\n                )}\n              >\n                {placeholder}\n              </span>\n            )}\n            <span className=\"sr-only\">, edit {label}</span>\n          </button>\n        )}\n\n        <span className={iconSlot}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {status === \"pending\" ? (\n              <motion.span\n                key=\"pending\"\n                className=\"flex\"\n                initial={motionOut}\n                animate={motionIn}\n                exit={motionOut}\n                transition={SPRING_SWAP}\n              >\n                <Loader\n                  variant=\"spinner\"\n                  size={14}\n                  label=\"Saving\"\n                  className=\"text-muted-foreground\"\n                />\n              </motion.span>\n            ) : status === \"saved\" ? (\n              <motion.span\n                key=\"saved\"\n                className=\"flex size-4 items-center justify-center rounded-full bg-success text-background\"\n                initial={motionOut}\n                animate={motionIn}\n                exit={{ opacity: 0 }}\n                transition={SPRING_PRESS}\n              >\n                <svg viewBox=\"0 0 24 24\" fill=\"none\" className=\"size-3\" aria-hidden=\"true\">\n                  <motion.path\n                    d=\"M5 12.5l4.5 4.5L19 7.5\"\n                    stroke=\"currentColor\"\n                    strokeWidth={3}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                    animate={{ pathLength: 1 }}\n                    transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.05 }}\n                  />\n                </svg>\n              </motion.span>\n            ) : !editing && !disabled ? (\n              <motion.span\n                key=\"pencil\"\n                className=\"flex text-muted-foreground\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.15 }}\n              >\n                <span className=\"flex opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100\">\n                  <svg\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={2}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    className=\"size-3.5\"\n                    aria-hidden=\"true\"\n                  >\n                    <path d=\"M12 20h9\" />\n                    <path d=\"M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z\" />\n                  </svg>\n                </span>\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </span>\n      </div>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {status === \"pending\" ? \"Saving\" : status === \"saved\" ? \"Saved\" : \"\"}\n      </span>\n\n      <AnimatePresence initial={false}>\n        {error ? (\n          <motion.p\n            id={errorId}\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-2 pt-1 text-xs text-destructive\",\n              classNames?.errorMessage,\n            )}\n          >\n            {error}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"},{"path":"components/motion/progress.tsx","type":"registry:component","target":"@components/motion/progress.tsx","content":"\"use client\";\n\nimport {\n  motion,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { type ReactNode, useEffect, useId, useRef } from \"react\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { EASE_IN_OUT, EASE_OUT, SPRING_GLIDE } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ProgressSize = \"sm\" | \"md\" | \"lg\";\n\n// Reduced motion: indeterminate states drop every transform and keep a calm\n// opacity pulse (same rhythm as Loader's reduced variant).\nconst REDUCED_PULSE = {\n  animate: { opacity: [0.35, 0.85, 0.35] },\n  transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\nconst clamp01 = (n: number) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0);\n\n/**\n * A spring-smoothed 0..1 fraction. Uses SPRING_GLIDE (critically damped) so\n * the fill never rebounds past the true value. Reduced motion jumps instead.\n */\nfunction useFraction(fraction: number) {\n  const reduce = useReducedMotion();\n  const spring = useSpring(0, SPRING_GLIDE);\n  useEffect(() => {\n    if (reduce) spring.jump(fraction);\n    else spring.set(fraction);\n  }, [fraction, reduce, spring]);\n  return spring;\n}\n\nfunction defaultValueText(percent: number) {\n  return `${percent}%`;\n}\n\ninterface ProgressCommonProps {\n  /** Current value. Omit (or pass `null`) together with `indeterminate`. */\n  value?: number | null;\n  /** Upper bound. Defaults to 100. */\n  max?: number;\n  /** Unknown-duration mode: a sliding segment (bar) or a spinning arc (circle). */\n  indeterminate?: boolean;\n  /** Visible label. When it is not a string, also pass `aria-label`. */\n  label?: ReactNode;\n  /** Show the rolling percentage. */\n  showValue?: boolean;\n  /** Screen-reader value text. Defaults to \"42%\". */\n  getValueText?: (percent: number, value: number, max: number) => string;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\nfunction useProgressState(\n  value: number | null | undefined,\n  max: number,\n  indeterminate: boolean,\n) {\n  const safeMax = max > 0 ? max : 100;\n  const isIndeterminate = indeterminate || value == null;\n  const current = isIndeterminate ? 0 : Math.min(safeMax, Math.max(0, value ?? 0));\n  const fraction = clamp01(current / safeMax);\n  const percent = Math.round(fraction * 100);\n  return { safeMax, isIndeterminate, current, fraction, percent };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  Progress                                  */\n/* -------------------------------------------------------------------------- */\n\nconst TRACK_SIZE: Record<ProgressSize, string> = {\n  sm: \"h-1\",\n  md: \"h-2\",\n  lg: \"h-3\",\n};\n\nexport interface ProgressProps extends ProgressCommonProps {\n  size?: ProgressSize;\n  /** Classes for the fill (e.g. `bg-emerald-500`). */\n  indicatorClassName?: string;\n  /** Classes for the track. */\n  trackClassName?: string;\n}\n\n/**\n * Linear progress bar. The fill is an HTML element scaled on X from its left\n * edge (transform only), smoothed by a critically damped spring.\n */\nexport function Progress({\n  value,\n  max = 100,\n  indeterminate = false,\n  label,\n  showValue = false,\n  size = \"md\",\n  getValueText = defaultValueText,\n  indicatorClassName,\n  trackClassName,\n  className,\n  \"aria-label\": ariaLabel,\n}: ProgressProps) {\n  const reduce = useReducedMotion();\n  const labelId = useId();\n  const { safeMax, isIndeterminate, current, fraction, percent } =\n    useProgressState(value, max, indeterminate);\n  const scaleX = useFraction(fraction);\n  const hasHeader = label != null || (showValue && !isIndeterminate);\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={ariaLabel ?? (typeof label === \"string\" ? label : undefined)}\n      aria-labelledby={ariaLabel == null && label != null && typeof label !== \"string\" ? labelId : undefined}\n      aria-valuemin={0}\n      aria-valuemax={safeMax}\n      aria-valuenow={isIndeterminate ? undefined : current}\n      aria-valuetext={\n        isIndeterminate ? undefined : getValueText(percent, current, safeMax)\n      }\n      className={cn(\"flex w-full flex-col gap-2\", className)}\n    >\n      {hasHeader ? (\n        <div className=\"flex items-baseline justify-between gap-3 text-sm\">\n          {label != null ? (\n            <span id={labelId} className=\"min-w-0 truncate font-medium text-foreground\">\n              {label}\n            </span>\n          ) : (\n            <span />\n          )}\n          {showValue && !isIndeterminate ? (\n            <span aria-hidden=\"true\" className=\"shrink-0 text-muted-foreground\">\n              <NumberTicker\n                value={percent}\n                suffix=\"%\"\n                startOnView={false}\n                duration={0.45}\n                stagger={0}\n              />\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n      <div\n        className={cn(\n          \"relative w-full overflow-hidden rounded-full bg-foreground/10\",\n          TRACK_SIZE[size],\n          trackClassName,\n        )}\n      >\n        {isIndeterminate ? (\n          reduce ? (\n            <motion.div\n              className={cn(\"absolute inset-0 rounded-full bg-primary\", indicatorClassName)}\n              animate={REDUCED_PULSE.animate}\n              transition={REDUCED_PULSE.transition}\n            />\n          ) : (\n            // A 40%-wide segment travels from fully off the left edge\n            // (-100% of itself) to fully off the right (250% of itself).\n            <motion.div\n              className={cn(\n                \"absolute inset-y-0 left-0 w-2/5 rounded-full bg-primary\",\n                indicatorClassName,\n              )}\n              initial={{ x: \"-100%\" }}\n              animate={{ x: \"250%\" }}\n              transition={{ duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }}\n            />\n          )\n        ) : (\n          <motion.div\n            className={cn(\n              \"absolute inset-0 origin-left rounded-full bg-primary\",\n              indicatorClassName,\n            )}\n            style={{ scaleX }}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                               ProgressCircle                               */\n/* -------------------------------------------------------------------------- */\n\nexport interface ProgressCircleProps extends ProgressCommonProps {\n  /** Diameter in px. */\n  size?: number;\n  /** Stroke width in px. */\n  thickness?: number;\n  /** Replace the centered percentage with custom content. */\n  children?: ReactNode;\n  /** Classes for the progress arc (stroke via `text-*`). */\n  indicatorClassName?: string;\n}\n\n/**\n * Ring progress. The arc draws with `pathLength`; the 12 o'clock start and\n * the indeterminate spin rotate whole HTML/SVG boxes with CSS transforms, so\n * no transform-origin is ever set on an inner SVG shape.\n */\nexport function ProgressCircle({\n  value,\n  max = 100,\n  indeterminate = false,\n  label,\n  showValue = true,\n  size = 64,\n  thickness,\n  getValueText = defaultValueText,\n  children,\n  indicatorClassName,\n  className,\n  \"aria-label\": ariaLabel,\n}: ProgressCircleProps) {\n  const reduce = useReducedMotion();\n  const labelId = useId();\n  const { safeMax, isIndeterminate, current, fraction, percent } =\n    useProgressState(value, max, indeterminate);\n  const stroke = thickness ?? Math.max(3, Math.round(size * 0.09));\n  const r = (size - stroke) / 2;\n  const c = size / 2;\n  const pathLength = useFraction(fraction);\n  // A round cap on a zero-length path still paints a dot; hide it at 0.\n  const arcOpacity = useTransform(pathLength, (v) => (v > 0.002 ? 1 : 0));\n  const fontSize = Math.max(10, Math.round(size * 0.22));\n\n  const center =\n    children ??\n    (showValue && !isIndeterminate ? (\n      <NumberTicker\n        value={percent}\n        suffix=\"%\"\n        startOnView={false}\n        duration={0.45}\n        stagger={0}\n        className=\"font-semibold text-foreground\"\n      />\n    ) : null);\n\n  const ring = (\n    <svg\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      // Start at 12 o'clock: rotate the whole <svg> box with CSS.\n      className=\"-rotate-90\"\n    >\n      <circle\n        cx={c}\n        cy={c}\n        r={r}\n        fill=\"none\"\n        strokeWidth={stroke}\n        className=\"stroke-foreground/10\"\n      />\n      {isIndeterminate ? (\n        <motion.circle\n          cx={c}\n          cy={c}\n          r={r}\n          fill=\"none\"\n          strokeWidth={stroke}\n          strokeLinecap=\"round\"\n          className={cn(\"stroke-current text-primary\", indicatorClassName)}\n          initial={false}\n          animate={{ pathLength: 0.28 }}\n          transition={{ duration: 0 }}\n        />\n      ) : (\n        <motion.circle\n          cx={c}\n          cy={c}\n          r={r}\n          fill=\"none\"\n          strokeWidth={stroke}\n          strokeLinecap=\"round\"\n          className={cn(\"stroke-current text-primary\", indicatorClassName)}\n          style={{ pathLength, opacity: arcOpacity }}\n        />\n      )}\n    </svg>\n  );\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={ariaLabel ?? (typeof label === \"string\" ? label : undefined)}\n      aria-labelledby={ariaLabel == null && label != null && typeof label !== \"string\" ? labelId : undefined}\n      aria-valuemin={0}\n      aria-valuemax={safeMax}\n      aria-valuenow={isIndeterminate ? undefined : current}\n      aria-valuetext={\n        isIndeterminate ? undefined : getValueText(percent, current, safeMax)\n      }\n      className={cn(\"inline-flex flex-col items-center gap-2\", className)}\n    >\n      <div className=\"relative\" style={{ width: size, height: size }}>\n        {isIndeterminate ? (\n          // The spin lives on an HTML wrapper, never on an SVG child.\n          <motion.div\n            className=\"size-full\"\n            animate={reduce ? REDUCED_PULSE.animate : { rotate: 360 }}\n            transition={\n              reduce\n                ? REDUCED_PULSE.transition\n                : { duration: 1, ease: \"linear\", repeat: Infinity }\n            }\n          >\n            {ring}\n          </motion.div>\n        ) : (\n          ring\n        )}\n        {center != null ? (\n          <div\n            aria-hidden=\"true\"\n            className=\"absolute inset-0 flex items-center justify-center tabular-nums\"\n            style={{ fontSize }}\n          >\n            {center}\n          </div>\n        ) : null}\n      </div>\n      {label != null ? (\n        <span id={labelId} className=\"text-xs text-muted-foreground\">\n          {label}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                ProgressSteps                               */\n/* -------------------------------------------------------------------------- */\n\nexport type ProgressStep = { label: string };\n\nexport interface ProgressStepsProps {\n  /** Number of segments, or labelled steps (labels render under segments). */\n  steps: number | ProgressStep[];\n  /**\n   * Completed steps. Fractions partially fill the next segment, e.g. `2.5`\n   * fills two segments and half of the third.\n   */\n  value: number;\n  size?: ProgressSize;\n  /** Hide the step labels even when `steps` carries them. */\n  hideLabels?: boolean;\n  \"aria-label\"?: string;\n  indicatorClassName?: string;\n  className?: string;\n}\n\n// Delay between neighbouring segments so a jump of several steps reads as a\n// sequence (stage 1 completes, then stage 2…) instead of all at once.\nconst SEGMENT_STAGGER = 0.14;\n\n/**\n * Segmented progress: N tracks that fill one after another, e.g. upload\n * stages or onboarding steps. Increases fill left→right; decreases unwind\n * right→left.\n */\nexport function ProgressSteps({\n  steps,\n  value,\n  size = \"md\",\n  hideLabels = false,\n  indicatorClassName,\n  className,\n  \"aria-label\": ariaLabel = \"Progress\",\n}: ProgressStepsProps) {\n  const reduce = useReducedMotion();\n  const list: ProgressStep[] =\n    typeof steps === \"number\"\n      ? Array.from({ length: Math.max(1, steps) }, (_, i) => ({ label: `Step ${i + 1}` }))\n      : steps;\n  const count = list.length;\n  const current = Math.min(count, Math.max(0, Number.isFinite(value) ? value : 0));\n  const showLabels = !hideLabels && typeof steps !== \"number\";\n\n  // Previous value decides the sequence direction and where it starts.\n  const prevRef = useRef(current);\n  const from = prevRef.current;\n  useEffect(() => {\n    prevRef.current = current;\n  }, [current]);\n  const growing = current >= from;\n\n  const activeIndex = Math.min(count - 1, Math.floor(current));\n  const valueText =\n    current >= count\n      ? `All ${count} steps complete`\n      : `Step ${activeIndex + 1} of ${count}: ${list[activeIndex]?.label ?? \"\"}`;\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={ariaLabel}\n      aria-valuemin={0}\n      aria-valuemax={count}\n      aria-valuenow={current}\n      aria-valuetext={valueText}\n      className={cn(\"flex w-full flex-col gap-2\", className)}\n    >\n      <div className=\"flex w-full gap-1.5\">\n        {list.map((_step, i) => {\n          const fill = clamp01(current - i);\n          const order = growing\n            ? Math.max(0, i - Math.floor(from))\n            : Math.max(0, Math.ceil(from) - 1 - i);\n          return (\n            <div\n              // biome-ignore lint/suspicious/noArrayIndexKey: segments are positional and never reorder\n              key={i}\n              className={cn(\n                \"relative flex-1 overflow-hidden rounded-full bg-foreground/10\",\n                TRACK_SIZE[size],\n              )}\n            >\n              <motion.div\n                className={cn(\n                  \"absolute inset-0 origin-left rounded-full bg-primary\",\n                  indicatorClassName,\n                )}\n                initial={false}\n                animate={{ scaleX: fill }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : { duration: 0.32, ease: EASE_OUT, delay: order * SEGMENT_STAGGER }\n                }\n              />\n            </div>\n          );\n        })}\n      </div>\n      {showLabels ? (\n        <div aria-hidden=\"true\" className=\"flex w-full gap-1.5\">\n          {list.map((step, i) => (\n            <span\n              // biome-ignore lint/suspicious/noArrayIndexKey: segments are positional and never reorder\n              key={i}\n              className={cn(\n                \"min-w-0 flex-1 truncate text-xs transition-colors duration-200\",\n                i < current ? \"text-foreground\" : \"text-muted-foreground\",\n              )}\n            >\n              {step.label}\n            </span>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"components/motion/scroll-area.tsx","type":"registry:component","target":"@components/motion/scroll-area.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n// ScrollArea keeps the browser's own scrolling (wheel, touch momentum,\n// keyboard, scroll anchoring, find-in-page) and only replaces the *painted*\n// scrollbar: the native bar is hidden and an overlay thumb is drawn on top.\n// The thumb is a visual affordance (aria-hidden); keyboard and assistive tech\n// scroll the focusable viewport itself.\n\nexport type ScrollAreaOrientation = \"vertical\" | \"horizontal\" | \"both\";\n/** `hover`: visible while hovered or scrolling. `scroll`: only while scrolling. `always`: whenever content overflows. */\nexport type ScrollAreaType = \"hover\" | \"scroll\" | \"always\";\n\nexport interface ScrollAreaProps extends Omit<HTMLAttributes<HTMLDivElement>, \"dir\"> {\n  children?: ReactNode;\n  orientation?: ScrollAreaOrientation;\n  type?: ScrollAreaType;\n  /** Milliseconds of idle before the thumb fades out. */\n  scrollHideDelay?: number;\n  /** Fade the content out at edges that have more content beyond them. */\n  fadeEdges?: boolean;\n  /** Maximum fade length in px; it grows with the distance from the edge. */\n  fadeSize?: number;\n  /** Class for the scrolling viewport (padding usually belongs here). */\n  viewportClassName?: string;\n  /** Ref to the native scroll container, for `scrollTo` or virtualizers. */\n  viewportRef?: Ref<HTMLElement | null>;\n  /** Accessible name for the focusable scroll region. */\n  \"aria-label\"?: string;\n}\n\nconst MIN_THUMB = 24;\nconst TRACK_INSET = 2;\n/** Fraction of the visible size a track click pages by (keeps a little context). */\nconst PAGE_FACTOR = 0.9;\n\ntype Axis = \"x\" | \"y\";\n\nexport function ScrollArea({\n  children,\n  orientation = \"vertical\",\n  type = \"hover\",\n  scrollHideDelay = 700,\n  fadeEdges = true,\n  fadeSize = 28,\n  className,\n  viewportClassName,\n  viewportRef,\n  \"aria-label\": ariaLabel,\n  onPointerEnter,\n  onPointerLeave,\n  ...rest\n}: ScrollAreaProps) {\n  const reduce = useReducedMotion();\n  const viewport = useRef<HTMLElement>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n  const tracks = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });\n  const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const hovering = useRef(false);\n  const dragging = useRef<{\n    axis: Axis;\n    pointer: number;\n    scroll: number;\n    ratio: number;\n  } | null>(null);\n\n  const [overflow, setOverflow] = useState({ x: false, y: false });\n  const [visible, setVisible] = useState(false);\n\n  useImperativeHandle(viewportRef, () => viewport.current as HTMLElement, []);\n\n  const allowX = orientation !== \"vertical\";\n  const allowY = orientation !== \"horizontal\";\n\n  // Geometry is written straight to the DOM on every scroll frame so a long\n  // list never re-renders React while scrolling.\n  const update = useCallback(() => {\n    const el = viewport.current;\n    if (!el) return;\n    const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = el;\n    const hasY = allowY && scrollHeight - clientHeight > 1;\n    const hasX = allowX && scrollWidth - clientWidth > 1;\n    setOverflow((prev) => (prev.x === hasX && prev.y === hasY ? prev : { x: hasX, y: hasY }));\n\n    const place = (axis: Axis) => {\n      const thumb = thumbs.current[axis];\n      const track = tracks.current[axis];\n      if (!thumb || !track) return;\n      const trackLen = axis === \"y\" ? track.clientHeight : track.clientWidth;\n      const view = axis === \"y\" ? clientHeight : clientWidth;\n      const total = axis === \"y\" ? scrollHeight : scrollWidth;\n      const pos = axis === \"y\" ? scrollTop : scrollLeft;\n      const len = Math.max(MIN_THUMB, Math.min(trackLen, (view / total) * trackLen));\n      const max = total - view;\n      const offset = max > 0 ? (pos / max) * (trackLen - len) : 0;\n      // The thumb's length is set (not animated) whenever content resizes;\n      // its motion along the track is a transform.\n      if (axis === \"y\") {\n        thumb.style.height = `${len}px`;\n        thumb.style.transform = `translate3d(0, ${offset}px, 0)`;\n      } else {\n        thumb.style.width = `${len}px`;\n        thumb.style.transform = `translate3d(${offset}px, 0, 0)`;\n      }\n    };\n    if (hasY) place(\"y\");\n    if (hasX) place(\"x\");\n\n    if (fadeEdges) {\n      // Fade length tracks the distance to each edge, so the mask eases in\n      // and out as you leave or reach an end instead of popping.\n      const top = hasY ? Math.min(fadeSize, scrollTop) : 0;\n      const bottom = hasY ? Math.min(fadeSize, scrollHeight - clientHeight - scrollTop) : 0;\n      const left = hasX ? Math.min(fadeSize, scrollLeft) : 0;\n      const right = hasX ? Math.min(fadeSize, scrollWidth - clientWidth - scrollLeft) : 0;\n      el.style.setProperty(\"--sa-top\", `${Math.max(0, top)}px`);\n      el.style.setProperty(\"--sa-bottom\", `${Math.max(0, bottom)}px`);\n      el.style.setProperty(\"--sa-left\", `${Math.max(0, left)}px`);\n      el.style.setProperty(\"--sa-right\", `${Math.max(0, right)}px`);\n    }\n  }, [allowX, allowY, fadeEdges, fadeSize]);\n\n  const scheduleHide = useCallback(() => {\n    clearTimeout(hideTimer.current);\n    hideTimer.current = setTimeout(() => {\n      if (dragging.current) return;\n      if (type === \"hover\" && hovering.current) return;\n      setVisible(false);\n    }, scrollHideDelay);\n  }, [scrollHideDelay, type]);\n\n  const reveal = useCallback(() => {\n    setVisible(true);\n    scheduleHide();\n  }, [scheduleHide]);\n\n  useEffect(() => {\n    const el = viewport.current;\n    const inner = content.current;\n    if (!el || !inner) return;\n    update();\n    const observer = new ResizeObserver(() => update());\n    observer.observe(el);\n    observer.observe(inner);\n    return () => observer.disconnect();\n  }, [update]);\n\n  // Bars mount only once overflow is known; place their thumbs right after.\n  useEffect(() => {\n    if (overflow.x || overflow.y) update();\n  }, [overflow.x, overflow.y, update]);\n\n  useEffect(() => () => clearTimeout(hideTimer.current), []);\n\n  const onScroll = () => {\n    update();\n    if (type !== \"always\") reveal();\n  };\n\n  const startDrag = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {\n    const el = viewport.current;\n    const track = tracks.current[axis];\n    const thumb = thumbs.current[axis];\n    if (!el || !track || !thumb || event.button !== 0) return;\n    event.preventDefault();\n    event.stopPropagation();\n    const trackLen = axis === \"y\" ? track.clientHeight : track.clientWidth;\n    const thumbLen = axis === \"y\" ? thumb.offsetHeight : thumb.offsetWidth;\n    const max = axis === \"y\" ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth;\n    dragging.current = {\n      axis,\n      pointer: axis === \"y\" ? event.clientY : event.clientX,\n      scroll: axis === \"y\" ? el.scrollTop : el.scrollLeft,\n      ratio: trackLen - thumbLen > 0 ? max / (trackLen - thumbLen) : 0,\n    };\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setVisible(true);\n    clearTimeout(hideTimer.current);\n  };\n\n  const onDragMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    const drag = dragging.current;\n    const el = viewport.current;\n    if (!drag || !el) return;\n    const delta = (drag.axis === \"y\" ? event.clientY : event.clientX) - drag.pointer;\n    if (drag.axis === \"y\") el.scrollTop = drag.scroll + delta * drag.ratio;\n    else el.scrollLeft = drag.scroll + delta * drag.ratio;\n  };\n\n  const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!dragging.current) return;\n    dragging.current = null;\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n    if (type !== \"always\") scheduleHide();\n  };\n\n  // Clicking the empty track pages one viewport toward the click, like a\n  // native scrollbar track.\n  const pageTrack = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {\n    const el = viewport.current;\n    const thumb = thumbs.current[axis];\n    if (!el || !thumb || event.button !== 0 || event.target !== event.currentTarget) return;\n    event.preventDefault();\n    const rect = thumb.getBoundingClientRect();\n    const before = axis === \"y\" ? event.clientY < rect.top : event.clientX < rect.left;\n    const page = (axis === \"y\" ? el.clientHeight : el.clientWidth) * PAGE_FACTOR;\n    const amount = before ? -page : page;\n    el.scrollBy({\n      top: axis === \"y\" ? amount : 0,\n      left: axis === \"x\" ? amount : 0,\n      behavior: reduce ? \"auto\" : \"smooth\",\n    });\n  };\n\n  const fade = \"linear-gradient(to bottom, transparent 0, black var(--sa-top), black calc(100% - var(--sa-bottom)), transparent 100%)\";\n  const fadeX = \"linear-gradient(to right, transparent 0, black var(--sa-left), black calc(100% - var(--sa-right)), transparent 100%)\";\n  const mask = fadeEdges\n    ? allowX && allowY\n      ? `${fade}, ${fadeX}`\n      : allowX\n        ? fadeX\n        : fade\n    : undefined;\n  const viewportStyle: CSSProperties | undefined = mask\n    ? ({\n        \"--sa-top\": \"0px\",\n        \"--sa-bottom\": \"0px\",\n        \"--sa-left\": \"0px\",\n        \"--sa-right\": \"0px\",\n        maskImage: mask,\n        WebkitMaskImage: mask,\n        maskComposite: allowX && allowY ? \"intersect\" : undefined,\n        WebkitMaskComposite: allowX && allowY ? \"source-in\" : undefined,\n      } as CSSProperties)\n    : undefined;\n\n  const shown = type === \"always\" || visible;\n  const both = overflow.x && overflow.y;\n\n  const renderBar = (axis: Axis) => (\n    <motion.div\n      aria-hidden\n      ref={(node: HTMLDivElement | null) => {\n        tracks.current[axis] = node;\n      }}\n      data-orientation={axis === \"y\" ? \"vertical\" : \"horizontal\"}\n      initial={false}\n      animate={{ opacity: shown ? 1 : 0 }}\n      transition={{ duration: shown ? 0.15 : 0.3, ease: EASE_OUT }}\n      onPointerDown={pageTrack(axis)}\n      className={cn(\n        \"group/scroll-track absolute z-10 flex touch-none select-none\",\n        !shown && \"pointer-events-none\",\n        axis === \"y\" ? \"w-2.5 justify-center\" : \"h-2.5 flex-col justify-center\",\n      )}\n      style={\n        axis === \"y\"\n          ? { top: TRACK_INSET, right: TRACK_INSET, bottom: both ? 12 : TRACK_INSET }\n          : { left: TRACK_INSET, bottom: TRACK_INSET, right: both ? 12 : TRACK_INSET }\n      }\n    >\n      <div\n        ref={(node) => {\n          thumbs.current[axis] = node;\n        }}\n        onPointerDown={startDrag(axis)}\n        onPointerMove={onDragMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        className={cn(\n          \"absolute rounded-full bg-foreground/25 transition-colors hover:bg-foreground/45 active:bg-foreground/55 will-change-transform\",\n          axis === \"y\" ? \"top-0 w-1.5 group-hover/scroll-track:w-2\" : \"left-0 h-1.5 group-hover/scroll-track:h-2\",\n        )}\n      />\n    </motion.div>\n  );\n\n  return (\n    <div\n      className={cn(\"relative overflow-hidden\", className)}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        hovering.current = true;\n        if (type === \"hover\") {\n          setVisible(true);\n          clearTimeout(hideTimer.current);\n        }\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        hovering.current = false;\n        if (type === \"hover\") scheduleHide();\n      }}\n      {...rest}\n    >\n      {/* The viewport is focusable (tabIndex 0) so keyboard users can scroll\n          it (WCAG 2.1.1); pass `aria-label` to name the region. */}\n      <section\n        ref={viewport}\n        aria-label={ariaLabel}\n        // biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable for keyboard scrolling (WCAG 2.1.1).\n        tabIndex={0}\n        onScroll={onScroll}\n        style={viewportStyle}\n        className={cn(\n          \"size-full outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n          \"focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset\",\n          allowY ? \"overflow-y-auto\" : \"overflow-y-hidden\",\n          allowX ? \"overflow-x-auto\" : \"overflow-x-hidden\",\n          viewportClassName,\n        )}\n      >\n        {/* Horizontal content is laid out at its own width so the observer\n            sees it grow; vertical content keeps the viewport's width. */}\n        <div ref={content} className={cn(allowX ? \"w-max min-w-full\" : \"min-w-full\")}>\n          {children}\n        </div>\n      </section>\n      {overflow.y ? renderBar(\"y\") : null}\n      {overflow.x ? renderBar(\"x\") : null}\n    </div>\n  );\n}\n"},{"path":"components/motion/select.tsx","type":"registry:component","target":"@components/motion/select.tsx","content":"\"use client\";\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport {\n  motion,\n  type Transition,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nconst INSTANT_TRANSITION: Transition = { duration: 0 };\n\n// Spring with bounce powers the unfold/separation; per-property timings in the\n// content choreograph it (see SelectContent). Mirrors bouncy-accordion's feel.\nconst CHEVRON_TRANSITION: Transition = { type: \"spring\", duration: 0.4, bounce: 0.3 };\n\nconst LIST_VARIANTS: Variants = {\n  hidden: {},\n  show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } },\n};\nconst ITEM_VARIANTS: Variants = {\n  hidden: { opacity: 0, y: -6, filter: \"blur(3px)\" },\n  show: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n};\n\ntype Placement = \"bottom\" | \"top\";\n\ninterface SelectContextValue {\n  value: string | undefined;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  select: (value: string) => void;\n  register: (value: string, label: string) => void;\n  unregister: (value: string) => void;\n  labelFor: (value: string | undefined) => string | undefined;\n  reduce: boolean;\n  triggerId: string;\n  listId: string;\n  disabled: boolean;\n  placement: Placement;\n  setPlacement: (p: Placement) => void;\n}\n\nconst SelectContext = createContext<SelectContextValue | null>(null);\n\nfunction useSelectContext(component: string) {\n  const ctx = useContext(SelectContext);\n  if (!ctx) throw new Error(`${component} must be used within <Select>`);\n  return ctx;\n}\n\nexport interface SelectProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Controlled open state of the panel. A layout that stacks selects can hold\n   * this to keep exactly one panel open — the panel is absolutely positioned\n   * inside its field, so two open at once paint over each other's options.\n   */\n  open?: boolean;\n  /** Uncontrolled initial open state. Default false. */\n  defaultOpen?: boolean;\n  /**\n   * Fires whenever the panel opens or closes. The panel is absolutely\n   * positioned inside the field, so a layout that stacks selects has to know\n   * which one is open to paint it above its neighbours.\n   */\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function Select({\n  value,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n  className,\n  children,\n}: SelectProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internal, setInternal] = useState(defaultValue);\n  const [labels, setLabels] = useState<Map<string, string>>(new Map());\n  const [placement, setPlacement] = useState<Placement>(\"bottom\");\n\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const openControlled = openProp !== undefined;\n  const open = openControlled ? openProp : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange, openControlled],\n  );\n\n  const select = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onValueChange?.(next);\n      setOpen(false);\n    },\n    [controlled, onValueChange, setOpen],\n  );\n\n  const register = useCallback((v: string, label: string) => {\n    setLabels((m) => (m.get(v) === label ? m : new Map(m).set(v, label)));\n  }, []);\n  const unregister = useCallback((v: string) => {\n    setLabels((m) => {\n      if (!m.has(v)) return m;\n      const next = new Map(m);\n      next.delete(v);\n      return next;\n    });\n  }, []);\n\n  // close on outside pointer / escape\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n    const onPointer = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node))\n        setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open, setOpen]);\n\n  const ctx = useMemo<SelectContextValue>(\n    () => ({\n      value: current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labelFor: (v) => (v === undefined ? undefined : labels.get(v)),\n      reduce,\n      triggerId: `${baseId}-trigger`,\n      listId: `${baseId}-list`,\n      disabled,\n      placement,\n      setPlacement,\n    }),\n    [\n      current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labels,\n      reduce,\n      baseId,\n      disabled,\n      placement,\n    ],\n  );\n\n  return (\n    <SelectContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative\", className)}>\n        {children}\n      </div>\n    </SelectContext.Provider>\n  );\n}\n\nexport interface SelectTriggerProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectTrigger({ className, children }: SelectTriggerProps) {\n  const ctx = useSelectContext(\"SelectTrigger\");\n  const isTop = ctx.placement === \"top\";\n  // edge facing the panel flattens then rounds; the far edge stays rounded.\n  // All four corners are specified so none gets stranded when placement flips.\n  const kf = ctx.open ? [0, 0, 12] : [12, 0, 12];\n  const kfT: Transition = ctx.reduce\n    ? { duration: 0 }\n    : ctx.open\n      ? { duration: 0.6, times: [0, 0.4, 1], ease: EASE_OUT }\n      : { duration: 0.42, times: [0, 0.5, 1], ease: EASE_OUT };\n  return (\n    <motion.button\n      type=\"button\"\n      id={ctx.triggerId}\n      disabled={ctx.disabled}\n      aria-haspopup=\"listbox\"\n      aria-expanded={ctx.open}\n      aria-controls={ctx.listId}\n      onClick={() => ctx.setOpen(!ctx.open)}\n      // Gooey: the edge facing the panel snaps flat (panel attached) then rounds\n      // back once the panel pulls away — the two pinch apart.\n      initial={false}\n      animate={{\n        borderTopLeftRadius: isTop ? kf : 12,\n        borderTopRightRadius: isTop ? kf : 12,\n        borderBottomLeftRadius: isTop ? 12 : kf,\n        borderBottomRightRadius: isTop ? 12 : kf,\n      }}\n      transition={{\n        borderTopLeftRadius: isTop ? kfT : INSTANT_TRANSITION,\n        borderTopRightRadius: isTop ? kfT : INSTANT_TRANSITION,\n        borderBottomLeftRadius: isTop ? INSTANT_TRANSITION : kfT,\n        borderBottomRightRadius: isTop ? INSTANT_TRANSITION : kfT,\n      }}\n      className={cn(\n        \"relative z-10 flex w-full items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors\",\n        \"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        TOUCH_TARGET_CLASS,\n        className,\n      )}\n    >\n      {children}\n      <motion.span\n        aria-hidden\n        animate={{ rotate: ctx.open ? 180 : 0 }}\n        transition={ctx.reduce ? { duration: 0 } : CHEVRON_TRANSITION}\n        className=\"text-muted-foreground\"\n      >\n        <ChevronDown className=\"h-4 w-4\" />\n      </motion.span>\n    </motion.button>\n  );\n}\n\nexport interface SelectValueProps {\n  placeholder?: string;\n  className?: string;\n}\n\nexport function SelectValue({ placeholder, className }: SelectValueProps) {\n  const ctx = useSelectContext(\"SelectValue\");\n  const label = ctx.labelFor(ctx.value);\n  return (\n    <span\n      className={cn(label ? \"text-foreground\" : \"text-muted-foreground\", className)}\n    >\n      {label ?? placeholder ?? \"Select\"}\n    </span>\n  );\n}\n\nexport interface SelectContentProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectContent({ className, children }: SelectContentProps) {\n  const ctx = useSelectContext(\"SelectContent\");\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState(0);\n  const open = ctx.open;\n  const { setPlacement } = ctx;\n\n  useLayoutEffect(() => {\n    const node = innerRef.current;\n    if (!node) return;\n    const measure = () => setHeight(node.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  });\n\n  // On open, flip upward when there isn't room below and there's more above.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const trigger = document.getElementById(ctx.triggerId);\n    const node = innerRef.current;\n    if (!trigger || !node) return;\n    const rect = trigger.getBoundingClientRect();\n    const h = node.offsetHeight;\n    const below = window.innerHeight - rect.bottom;\n    const above = rect.top;\n    setPlacement(below < h + 16 && above > below ? \"top\" : \"bottom\");\n  }, [open, ctx.triggerId, setPlacement]);\n\n  // Specify EVERY corner + both margins each render. The near edge (facing the\n  // trigger) animates flat->round and the gap opens on that side; the far edge\n  // stays rounded and its margin pinned to 0. Setting all of them avoids a\n  // stranded square corner when the placement flips between opens.\n  const isTop = ctx.placement === \"top\";\n  const nearGap = open ? 8 : 0;\n  const nearRadius = open ? 12 : 0;\n\n  const gapT: Transition = open\n    ? { type: \"spring\", duration: 0.6, bounce: 0.5, delay: 0.12 }\n    : { type: \"spring\", duration: 0.3, bounce: 0.1 };\n  const radiusT: Transition = open\n    ? { duration: 0.3, ease: EASE_OUT, delay: 0.14 }\n    : { duration: 0.16, ease: EASE_OUT };\n\n  // Items stay mounted (open just animates the panel) so each item's label\n  // registration persists — otherwise the trigger would fall back to the\n  // placeholder the moment the panel closes.\n  return (\n    <motion.div\n      id={ctx.listId}\n      role=\"listbox\"\n      aria-labelledby={ctx.triggerId}\n      aria-hidden={!open}\n      inert={!open}\n      initial={false}\n      animate={\n        ctx.reduce\n          ? { opacity: open ? 1 : 0, height: open ? height : 0 }\n          : {\n              opacity: open ? 1 : 0,\n              height: open ? height : 0,\n              // gap opens on the side facing the trigger\n              marginTop: isTop ? 0 : nearGap,\n              marginBottom: isTop ? nearGap : 0,\n              // near corners go flat->round; far corners stay rounded\n              borderTopLeftRadius: isTop ? 12 : nearRadius,\n              borderTopRightRadius: isTop ? 12 : nearRadius,\n              borderBottomLeftRadius: isTop ? nearRadius : 12,\n              borderBottomRightRadius: isTop ? nearRadius : 12,\n            }\n      }\n      transition={\n        ctx.reduce\n          ? { duration: 0.12 }\n          : {\n              opacity: open\n                ? { duration: 0.18 }\n                : { duration: 0.16, delay: 0.12 },\n              height: open\n                ? { type: \"spring\", duration: 0.42, bounce: 0.14 }\n                : { duration: 0.26, ease: EASE_OUT, delay: 0.14 },\n              marginTop: isTop ? INSTANT_TRANSITION : gapT,\n              marginBottom: isTop ? gapT : INSTANT_TRANSITION,\n              borderTopLeftRadius: isTop ? INSTANT_TRANSITION : radiusT,\n              borderTopRightRadius: isTop ? INSTANT_TRANSITION : radiusT,\n              borderBottomLeftRadius: isTop ? radiusT : INSTANT_TRANSITION,\n              borderBottomRightRadius: isTop ? radiusT : INSTANT_TRANSITION,\n            }\n      }\n      style={{\n        transformOrigin: isTop ? \"bottom\" : \"top\",\n        overflow: \"hidden\",\n        pointerEvents: open ? \"auto\" : \"none\",\n      }}\n      // flush against the trigger, then separates into its own rounded pill;\n      // sits above or below depending on available space\n      className={cn(\n        \"absolute left-0 right-0 z-20 rounded-xl border border-border bg-background shadow-lg\",\n        isTop ? \"bottom-full\" : \"top-full\",\n        className,\n      )}\n    >\n      <motion.div\n        ref={innerRef}\n        variants={ctx.reduce ? undefined : LIST_VARIANTS}\n        initial={false}\n        animate={open ? \"show\" : \"hidden\"}\n        className=\"p-1\"\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n}\n\nexport interface SelectItemProps {\n  value: string;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectItem({\n  value,\n  disabled = false,\n  className,\n  children,\n}: SelectItemProps) {\n  const ctx = useSelectContext(\"SelectItem\");\n  const selected = ctx.value === value;\n  const label = typeof children === \"string\" ? children : value;\n\n  useLayoutEffect(() => {\n    ctx.register(value, label);\n    return () => ctx.unregister(value);\n  }, [ctx.register, ctx.unregister, value, label]);\n\n  return (\n    <motion.li variants={ctx.reduce ? undefined : ITEM_VARIANTS}>\n      <button\n        type=\"button\"\n        role=\"option\"\n        aria-selected={selected}\n        disabled={disabled}\n        onClick={() => ctx.select(value)}\n        className={cn(\n          \"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors\",\n          selected\n            ? \"bg-muted text-foreground\"\n            : \"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          className,\n        )}\n      >\n        {children}\n        {selected ? <Check className=\"h-3.5 w-3.5 shrink-0\" /> : null}\n      </button>\n    </motion.li>\n  );\n}\n"},{"path":"components/motion/switch.tsx","type":"registry:component","target":"@components/motion/switch.tsx","content":"\"use client\";\n\nimport { animate, motion, MotionConfig, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\n// Heavy, deliberate thumb — high mass keeps the travel weighty without wobble.\nconst THUMB_SPRING = { type: \"spring\", stiffness: 800, damping: 80, mass: 4 } as const;\n\nexport interface SwitchProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  disabled?: boolean;\n  label?: string;\n  ariaLabel?: string;\n  className?: string;\n}\n\nexport function Switch({\n  checked,\n  onCheckedChange,\n  disabled,\n  label,\n  ariaLabel,\n  className,\n}: SwitchProps) {\n  const id = useId();\n  const thumbRef = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const [isPressed, setIsPressed] = useState(false);\n  const [isPointer, setIsPointer] = useState(false);\n\n  // Disabled shake feedback when pressed.\n  useEffect(() => {\n    if (!thumbRef.current || reduce) return;\n    if (disabled && isPressed) {\n      animate(\n        thumbRef.current,\n        { x: [0, -2, 2, -1, 0] },\n        { delay: 0.2, duration: 0.6 },\n      );\n    }\n  }, [disabled, isPressed, reduce]);\n\n  const squish = !disabled && isPointer && isPressed && !reduce;\n\n  return (\n    <MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>\n      <span className={cn(\"inline-flex items-center gap-3\", className)}>\n        <motion.button\n          id={id}\n          type=\"button\"\n          role=\"switch\"\n          aria-checked={checked}\n          aria-label={ariaLabel}\n          disabled={disabled}\n          onClick={() => !disabled && onCheckedChange(!checked)}\n          onPointerDown={(e) => {\n            setIsPressed(true);\n            setIsPointer(e.type.startsWith(\"pointer\"));\n          }}\n          onPointerUp={() => setIsPressed(false)}\n          onPointerLeave={() => setIsPressed(false)}\n          initial={false}\n          data-state={checked ? \"checked\" : \"unchecked\"}\n          className={cn(\n            \"group peer relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center px-1 rounded-full outline-none transition-colors duration-200\",\n            TOUCH_TARGET_CLASS,\n            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n            \"disabled:cursor-not-allowed disabled:opacity-60\",\n            checked ? \"justify-end bg-primary\" : \"justify-start bg-muted-foreground/60\",\n          )}\n        >\n          <motion.div\n            ref={thumbRef}\n            layout\n            animate={{ scale: squish ? 0.9 : 1 }}\n            className=\"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md\"\n          >\n            {/* Stretch toward the destination while active. */}\n            <div\n              className={cn(\n                \"size-5\",\n                squish && (checked ? \"ml-1\" : \"mr-1\"),\n              )}\n            />\n          </motion.div>\n        </motion.button>\n        {label ? (\n          <label htmlFor={id} className=\"cursor-pointer text-sm text-foreground\">\n            {label}\n          </label>\n        ) : null}\n      </span>\n    </MotionConfig>\n  );\n}\n"},{"path":"components/motion/toggle-group.tsx","type":"registry:component","target":"@components/motion/toggle-group.tsx","content":"\"use client\";\n\nimport {\n  AnimatePresence,\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  createContext,\n  forwardRef,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\n// Toggle / ToggleGroup vs SegmentedControl: `SegmentedControl`\n// (segmented-control.tsx) is a radiogroup form control that always holds one\n// value and ships a data-driven `ToggleGroup` for quick `items` arrays. This\n// file is the composable, shadcn-shaped toolbar primitive: a standalone\n// `Toggle`, and a `ToggleGroup` you fill with `ToggleGroupItem` children\n// (icons, labels, tooltips, anything), with `default`/`outline` variants. In\n// single mode one pressed surface glides between items; in multiple mode each\n// item pops its own surface in and out.\n\nexport type ToggleVariant = \"default\" | \"outline\";\nexport type ToggleSize = \"sm\" | \"md\" | \"lg\";\n\nconst sizeClass: Record<ToggleSize, string> = {\n  sm: \"h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5\",\n  md: \"h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4\",\n  lg: \"h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]\",\n};\n\nconst surfaceClass: Record<ToggleVariant, string> = {\n  default: \"bg-muted ring-1 ring-border-strong\",\n  outline: \"bg-muted\",\n};\n\nconst itemBase =\n  \"relative isolate inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium outline-none transition-colors duration-200 focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\";\n\nfunction useControllable<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange: ((next: T) => void) | undefined,\n) {\n  const [internal, setInternal] = useState<T>(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\ntype ButtonBaseProps = Omit<HTMLMotionProps<\"button\">, \"children\" | \"value\" | \"type\">;\n\n/** The pressed surface that pops in and out behind a standalone toggle or a multiple-mode item. */\nfunction PopSurface({ show, variant }: { show: boolean; variant: ToggleVariant }) {\n  const reduce = useReducedMotion();\n  return (\n    <AnimatePresence initial={false}>\n      {show ? (\n        <motion.span\n          key=\"on\"\n          aria-hidden\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={\n            reduce\n              ? { opacity: 0 }\n              : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }\n          }\n          transition={SPRING_SWAP}\n          className={cn(\"absolute inset-0 -z-10 rounded-[inherit]\", surfaceClass[variant])}\n        />\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\n/* ------------------------------------------------------------------------ */\n/* Toggle                                                                    */\n/* ------------------------------------------------------------------------ */\n\nexport interface ToggleProps extends ButtonBaseProps {\n  pressed?: boolean;\n  defaultPressed?: boolean;\n  onPressedChange?: (pressed: boolean) => void;\n  variant?: ToggleVariant;\n  size?: ToggleSize;\n  children?: ReactNode;\n}\n\n/** A two-state button (`aria-pressed`) whose pressed surface springs in and out. */\nexport const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(\n  {\n    pressed,\n    defaultPressed = false,\n    onPressedChange,\n    variant = \"default\",\n    size = \"md\",\n    disabled,\n    className,\n    children,\n    onClick,\n    ...rest\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const [on, setOn] = useControllable(pressed, defaultPressed, onPressedChange);\n  return (\n    <motion.button\n      ref={ref}\n      type=\"button\"\n      aria-pressed={on}\n      data-state={on ? \"on\" : \"off\"}\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.9 }}\n      transition={SPRING_PRESS}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) setOn(!on);\n      }}\n      className={cn(\n        itemBase,\n        TOUCH_TARGET_CLASS,\n        \"rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n        sizeClass[size],\n        variant === \"outline\" && \"border border-border\",\n        on\n          ? \"text-foreground\"\n          : \"text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground\",\n        className,\n      )}\n      {...rest}\n    >\n      <PopSurface show={on} variant={variant} />\n      {children}\n    </motion.button>\n  );\n});\n\n/* ------------------------------------------------------------------------ */\n/* ToggleGroup                                                               */\n/* ------------------------------------------------------------------------ */\n\ninterface ToggleGroupContextValue {\n  type: \"single\" | \"multiple\";\n  pressed: string[];\n  toggle: (value: string) => void;\n  variant: ToggleVariant;\n  size: ToggleSize;\n  disabled: boolean;\n  layoutId: string;\n  tabStop: string | null;\n  setTabStop: (value: string) => void;\n}\n\nconst ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);\n\ntype ToggleGroupBase = Omit<\n  HTMLMotionProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\" | \"dir\"\n> & {\n  variant?: ToggleVariant;\n  size?: ToggleSize;\n  disabled?: boolean;\n  /** Arrow-key axis for roving focus. Default \"horizontal\" (both axes still work). */\n  orientation?: \"horizontal\" | \"vertical\";\n  children?: ReactNode;\n};\n\nexport type ToggleGroupSingleProps = ToggleGroupBase & {\n  type: \"single\";\n  /** The pressed item, or `\"\"` when none is pressed. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Disallow un-pressing the active item, so one is always pressed. */\n  required?: boolean;\n};\n\nexport type ToggleGroupMultipleProps = ToggleGroupBase & {\n  type: \"multiple\";\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n};\n\nexport type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;\n\nconst ITEM_SELECTOR = \"[data-toggle-group-item]\";\n\nfunction enabledItems(root: HTMLElement | null) {\n  if (!root) return [];\n  return Array.from(root.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR)).filter(\n    (el) => !el.disabled && el.closest(\"[data-toggle-group]\") === root,\n  );\n}\n\n/**\n * Toolbar-style group of `ToggleGroupItem`s. `type=\"single\"` behaves like a\n * set of mutually exclusive toggles (deselectable unless `required`) whose\n * pressed surface glides between items; `type=\"multiple\"` presses any subset.\n * One tab stop, arrow keys / Home / End rove between items, Space/Enter press.\n */\nexport function ToggleGroup(props: ToggleGroupProps) {\n  const {\n    type,\n    value: _value,\n    defaultValue: _defaultValue,\n    onValueChange: _onValueChange,\n    variant = \"default\",\n    size = \"md\",\n    disabled = false,\n    orientation = \"horizontal\",\n    className,\n    children,\n    onKeyDown,\n    ...rest\n  } = props;\n  // `required` only exists on the single variant; keep it off the DOM.\n  const { required: _required, ...domProps } = rest as typeof rest & { required?: boolean };\n  const single = props.type === \"single\";\n  const required = props.type === \"single\" && Boolean(props.required);\n\n  const [pressed, setPressed] = useControllable<string[]>(\n    props.value === undefined\n      ? undefined\n      : props.type === \"single\"\n        ? props.value\n          ? [props.value]\n          : []\n        : props.value,\n    props.type === \"single\"\n      ? props.defaultValue\n        ? [props.defaultValue]\n        : []\n      : (props.defaultValue ?? []),\n    (next) => {\n      if (props.type === \"single\") props.onValueChange?.(next[0] ?? \"\");\n      else props.onValueChange?.(next);\n    },\n  );\n\n  const layoutId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [tabStop, setTabStop] = useState<string | null>(pressed[0] ?? null);\n\n  // Keep exactly one reachable tab stop: if the remembered item vanished or\n  // got disabled, fall back to the first pressed item, then the first enabled.\n  useEffect(() => {\n    const items = enabledItems(rootRef.current);\n    if (items.some((el) => el.dataset.value === tabStop)) return;\n    const fallback =\n      items.find((el) => pressed.includes(el.dataset.value ?? \"\")) ?? items[0];\n    const next = fallback?.dataset.value ?? null;\n    if (next !== tabStop) setTabStop(next);\n  });\n\n  const toggle = (itemValue: string) => {\n    const isOn = pressed.includes(itemValue);\n    if (single) {\n      if (isOn && required) return;\n      setPressed(isOn ? [] : [itemValue]);\n    } else {\n      setPressed(isOn ? pressed.filter((v) => v !== itemValue) : [...pressed, itemValue]);\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    const items = enabledItems(rootRef.current);\n    const index = items.indexOf(document.activeElement as HTMLButtonElement);\n    if (index < 0) return;\n    const forward = orientation === \"vertical\" ? \"ArrowDown\" : \"ArrowRight\";\n    const backward = orientation === \"vertical\" ? \"ArrowUp\" : \"ArrowLeft\";\n    let next: number;\n    if (event.key === forward || event.key === \"ArrowDown\" || event.key === \"ArrowRight\")\n      next = (index + 1) % items.length;\n    else if (event.key === backward || event.key === \"ArrowUp\" || event.key === \"ArrowLeft\")\n      next = (index - 1 + items.length) % items.length;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = items.length - 1;\n    else return;\n    event.preventDefault();\n    // Focus moves; pressing stays on Space/Enter — these are toggles, not radios.\n    items[next].focus();\n  };\n\n  return (\n    <ToggleGroupContext.Provider\n      value={{\n        type,\n        pressed,\n        toggle,\n        variant,\n        size,\n        disabled,\n        layoutId,\n        tabStop,\n        setTabStop,\n      }}\n    >\n      {/* layoutRoot keeps the gliding surface's projection local to the group,\n          so scrolled or transformed ancestors never skew the glide. */}\n      <motion.div\n        ref={rootRef}\n        layoutRoot\n        role=\"group\"\n        data-toggle-group=\"\"\n        data-orientation={orientation}\n        aria-disabled={disabled || undefined}\n        onKeyDown={handleKeyDown}\n        className={cn(\n          \"inline-flex w-fit items-center\",\n          orientation === \"vertical\" && \"flex-col items-stretch\",\n          variant === \"default\"\n            ? \"gap-0.5 rounded-xl border border-border bg-card p-1\"\n            : \"rounded-lg\",\n          disabled && \"opacity-60\",\n          className,\n        )}\n        {...domProps}\n      >\n        {children}\n      </motion.div>\n    </ToggleGroupContext.Provider>\n  );\n}\n\nexport interface ToggleGroupItemProps extends ButtonBaseProps {\n  value: string;\n  children?: ReactNode;\n}\n\nexport const ToggleGroupItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(\n  function ToggleGroupItem(\n    { value, disabled: itemDisabled, className, children, onClick, onFocus, ...rest },\n    ref,\n  ) {\n    const ctx = useContext(ToggleGroupContext);\n    if (!ctx) throw new Error(\"ToggleGroupItem must be used inside <ToggleGroup>.\");\n    const reduce = useReducedMotion();\n    const disabled = ctx.disabled || Boolean(itemDisabled);\n    const on = ctx.pressed.includes(value);\n    const outline = ctx.variant === \"outline\";\n\n    return (\n      <motion.button\n        ref={ref}\n        type=\"button\"\n        data-toggle-group-item=\"\"\n        data-value={value}\n        data-state={on ? \"on\" : \"off\"}\n        aria-pressed={on}\n        disabled={disabled}\n        tabIndex={ctx.tabStop === value ? 0 : -1}\n        whileTap={reduce || disabled ? undefined : { scale: 0.88 }}\n        transition={SPRING_PRESS}\n        onFocus={(event) => {\n          ctx.setTabStop(value);\n          onFocus?.(event);\n        }}\n        onClick={(event) => {\n          onClick?.(event);\n          if (event.defaultPrevented) return;\n          ctx.setTabStop(value);\n          ctx.toggle(value);\n        }}\n        className={cn(\n          itemBase,\n          TOUCH_TARGET_CLASS,\n          sizeClass[ctx.size],\n          outline\n            ? // Joined segments: neighbours overlap their shared border.\n              \"rounded-none border border-border bg-card -ml-px first:ml-0 first:rounded-l-lg last:rounded-r-lg in-data-[orientation=vertical]:-mt-px in-data-[orientation=vertical]:ml-0 in-data-[orientation=vertical]:first:mt-0 in-data-[orientation=vertical]:first:rounded-l-none in-data-[orientation=vertical]:first:rounded-t-lg in-data-[orientation=vertical]:last:rounded-r-none in-data-[orientation=vertical]:last:rounded-b-lg\"\n            : \"rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-card\",\n          on\n            ? \"text-foreground\"\n            : \"text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground\",\n          className,\n        )}\n        {...rest}\n      >\n        {ctx.type === \"single\" ? (\n          on ? (\n            <motion.span\n              layoutId={ctx.layoutId}\n              aria-hidden\n              transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n              className={cn(\n                \"absolute inset-0 -z-10 rounded-[inherit]\",\n                surfaceClass[ctx.variant],\n              )}\n            />\n          ) : null\n        ) : (\n          <PopSurface show={on} variant={ctx.variant} />\n        )}\n        {children}\n      </motion.button>\n    );\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":"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/loader.tsx","type":"registry:component","target":"@components/motion/loader.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useState } from \"react\";\nimport { EASE_IN_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type LoaderVariant =\n  | \"spinner\"\n  | \"dots\"\n  | \"bars\"\n  | \"dot-matrix\"\n  | \"dither\"\n  | \"ascii\"\n  | \"ascii-line\"\n  | \"ascii-braille\"\n  | \"ascii-blocks\"\n  | \"ascii-bounce\"\n  | \"morph\"\n  | \"comet\"\n  | \"scramble\"\n  | \"metaballs\"\n  | \"newton\"\n  | \"helix\"\n  | \"percent\";\n\n// Terminal-style frame sets — the loaders CLI AI agents cycle through.\nconst ASCII_SETS: Record<string, string[]> = {\n  ascii: [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"],\n  \"ascii-line\": [\"|\", \"/\", \"-\", \"\\\\\"],\n  \"ascii-braille\": [\"⣾\", \"⣽\", \"⣻\", \"⢿\", \"⡿\", \"⣟\", \"⣯\", \"⣷\"],\n  \"ascii-blocks\": [\"▁\", \"▂\", \"▃\", \"▄\", \"▅\", \"▆\", \"▇\", \"█\", \"▇\", \"▆\", \"▅\", \"▄\", \"▃\", \"▂\"],\n  \"ascii-bounce\": [\"⠁\", \"⠂\", \"⠄\", \"⡀\", \"⢀\", \"⠠\", \"⠐\", \"⠈\"],\n};\n\nexport interface LoaderProps {\n  /** Which animation to render. */\n  variant?: LoaderVariant;\n  /** Base square size in px. Everything scales from this. */\n  size?: number;\n  /** Seconds per animation cycle. */\n  speed?: number;\n  /** Accessible label announced to screen readers. */\n  label?: string;\n  className?: string;\n}\n\n// Reduced motion keeps a calm opacity pulse and drops every transform.\nconst REDUCED = {\n  animate: { opacity: [1, 0.4, 1] },\n  transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\nexport function Loader({\n  variant = \"spinner\",\n  size = 32,\n  speed = 1,\n  label = \"Loading\",\n  className,\n}: LoaderProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <span\n      role=\"status\"\n      aria-label={label}\n      className={cn(\n        \"inline-flex items-center justify-center text-foreground\",\n        className,\n      )}\n    >\n      {variant === \"spinner\" && <Spinner size={size} speed={speed} reduce={reduce} />}\n      {variant === \"dots\" && <Dots size={size} speed={speed} reduce={reduce} />}\n      {variant === \"bars\" && <Bars size={size} speed={speed} reduce={reduce} />}\n      {variant === \"dot-matrix\" && (\n        <DotMatrix size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"dither\" && <Dither size={size} speed={speed} reduce={reduce} />}\n      {ASCII_SETS[variant] && (\n        <Ascii frames={ASCII_SETS[variant]} size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"morph\" && <Morph size={size} speed={speed} reduce={reduce} />}\n      {variant === \"comet\" && <Comet size={size} speed={speed} reduce={reduce} />}\n      {variant === \"scramble\" && (\n        <Scramble size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"metaballs\" && (\n        <Metaballs size={size} speed={speed} reduce={reduce} />\n      )}\n      {variant === \"newton\" && <Newton size={size} speed={speed} reduce={reduce} />}\n      {variant === \"helix\" && <Helix size={size} speed={speed} reduce={reduce} />}\n      {variant === \"percent\" && (\n        <Percent size={size} speed={speed} reduce={reduce} />\n      )}\n      <span className=\"sr-only\">{label}</span>\n    </span>\n  );\n}\n\ninterface PartProps {\n  size: number;\n  speed: number;\n  reduce: boolean;\n}\n\nfunction Spinner({ size, speed, reduce }: PartProps) {\n  const stroke = Math.max(2, size * 0.09);\n  const r = (size - stroke) / 2;\n  return (\n    <motion.svg\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      animate={reduce ? REDUCED.animate : { rotate: 360 }}\n      transition={\n        reduce\n          ? REDUCED.transition\n          : { duration: speed, ease: \"linear\", repeat: Infinity }\n      }\n    >\n      <circle\n        cx={size / 2}\n        cy={size / 2}\n        r={r}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeOpacity={0.2}\n        strokeWidth={stroke}\n      />\n      <path\n        d={`M ${size / 2} ${size / 2 - r} A ${r} ${r} 0 0 1 ${size / 2 + r} ${size / 2}`}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={stroke}\n        strokeLinecap=\"round\"\n      />\n    </motion.svg>\n  );\n}\n\nfunction Dots({ size, speed, reduce }: PartProps) {\n  const dot = size * 0.24;\n  return (\n    <span className=\"flex items-center\" style={{ gap: size * 0.14 }}>\n      {[0, 1, 2].map((i) => (\n        <motion.span\n          key={i}\n          className=\"rounded-full bg-current\"\n          style={{ width: dot, height: dot }}\n          animate={\n            reduce\n              ? { opacity: [0.4, 1, 0.4] }\n              : { y: [0, -size * 0.3, 0], opacity: [0.5, 1, 0.5] }\n          }\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: i * speed * 0.16,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction Ascii({\n  frames,\n  size,\n  speed,\n  reduce,\n}: PartProps & { frames: string[] }) {\n  const [frame, setFrame] = useState(0);\n  useEffect(() => {\n    // Reduced motion slows the cycle rather than stopping it — it's a glyph\n    // swap, not on-screen movement.\n    const step = ((reduce ? speed * 2.5 : speed) / frames.length) * 1000;\n    const id = setInterval(\n      () => setFrame((f) => (f + 1) % frames.length),\n      step,\n    );\n    return () => clearInterval(id);\n  }, [frames.length, speed, reduce]);\n\n  return (\n    <span\n      className=\"font-mono leading-none tabular-nums\"\n      style={{ fontSize: size, lineHeight: 1 }}\n    >\n      {frames[frame % frames.length]}\n    </span>\n  );\n}\n\n// Each shape is sampled at the same number of points and emitted as an SVG\n// path with identical command structure, so framer tweens the `d` attribute\n// point-to-point — a real morph, not a snap. (clip-path polygon strings don't\n// interpolate reliably in framer, which left the shapes broken.)\nconst MORPH_POINTS = 24;\n\nfunction ngonRadius(ang: number, n: number, phase = 0) {\n  const seg = (2 * Math.PI) / n;\n  const a = ang - phase;\n  const local = (((a % seg) + seg) % seg) - seg / 2;\n  return Math.cos(Math.PI / n) / Math.cos(local);\n}\n\nfunction morphPath(radiusAt: (ang: number) => number) {\n  const parts: string[] = [];\n  for (let i = 0; i < MORPH_POINTS; i++) {\n    const ang = (i / MORPH_POINTS) * 2 * Math.PI - Math.PI / 2;\n    const r = Math.min(1.05, radiusAt(ang));\n    const x = (50 + Math.cos(ang) * 46 * r).toFixed(2);\n    const y = (50 + Math.sin(ang) * 46 * r).toFixed(2);\n    parts.push(`${i === 0 ? \"M\" : \"L\"}${x} ${y}`);\n  }\n  return `${parts.join(\" \")} Z`;\n}\n\nconst MORPH_PATHS = [\n  morphPath(() => 1), // circle\n  morphPath((a) => ngonRadius(a, 4, Math.PI / 4)), // square\n  morphPath((a) => ngonRadius(a, 3)), // triangle\n  morphPath((a) => ngonRadius(a, 6)), // hexagon\n  morphPath((a) => ngonRadius(a, 4)), // diamond\n];\n\n// Each shape appears twice in a row so it fully forms and HOLDS before the\n// next morph. Even keyframe spacing then alternates hold / morph segments.\nconst MORPH_SEQ = [...MORPH_PATHS.flatMap((p) => [p, p]), MORPH_PATHS[0]];\n// Rotation and scale only change across the morph segments, staying put on the\n// holds, so a settled shape sits still.\nconst MORPH_ROT = [0, 0, 72, 72, 144, 144, 216, 216, 288, 288, 360];\nconst MORPH_SCALE = [1, 1, 0.88, 0.88, 1, 1, 0.88, 0.88, 1, 1, 1];\n\nfunction Morph({ size, speed, reduce }: PartProps) {\n  return (\n    <motion.svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 100 100\"\n      role=\"img\"\n      animate={\n        reduce\n          ? { opacity: [1, 0.4, 1] }\n          : { rotate: MORPH_ROT, scale: MORPH_SCALE }\n      }\n      transition={\n        reduce\n          ? { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }\n          : { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }\n      }\n    >\n      <title>Loading</title>\n      <motion.path\n        fill=\"currentColor\"\n        d={MORPH_PATHS[0]}\n        animate={reduce ? undefined : { d: MORPH_SEQ }}\n        transition={\n          reduce\n            ? undefined\n            : { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }\n        }\n      />\n    </motion.svg>\n  );\n}\n\nconst COMET_TRAIL = [0, 1, 2, 3, 4, 5];\n\nfunction Comet({ size, speed, reduce }: PartProps) {\n  const head = size * 0.2;\n  const r = size / 2 - head / 2;\n  return (\n    <span className=\"relative\" style={{ width: size, height: size }}>\n      <motion.span\n        className=\"absolute inset-0\"\n        animate={reduce ? REDUCED.animate : { rotate: 360 }}\n        transition={\n          reduce\n            ? REDUCED.transition\n            : { duration: speed, ease: \"linear\", repeat: Infinity }\n        }\n      >\n        {COMET_TRAIL.map((i) => {\n          const scale = 1 - i * 0.13;\n          const sz = head * scale;\n          return (\n            <span\n              key={i}\n              className=\"absolute top-1/2 left-1/2 rounded-full bg-current\"\n              style={{\n                width: sz,\n                height: sz,\n                marginLeft: -sz / 2,\n                marginTop: -sz / 2,\n                opacity: 1 - i * 0.16,\n                transform: `rotate(${-i * 15}deg) translateY(${-r}px)`,\n              }}\n            />\n          );\n        })}\n      </motion.span>\n    </span>\n  );\n}\n\nconst SCRAMBLE_TARGET = \"LOADING\";\nconst SCRAMBLE_GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/*#@\";\n\nfunction Scramble({ size, speed, reduce }: PartProps) {\n  const [text, setText] = useState(SCRAMBLE_TARGET);\n  useEffect(() => {\n    if (reduce) {\n      setText(SCRAMBLE_TARGET);\n      return;\n    }\n    let tick = 0;\n    const total = SCRAMBLE_TARGET.length + 4;\n    const id = setInterval(\n      () => {\n        const reveal = tick % total;\n        let s = \"\";\n        for (let i = 0; i < SCRAMBLE_TARGET.length; i++) {\n          s +=\n            i < reveal\n              ? SCRAMBLE_TARGET[i]\n              : SCRAMBLE_GLYPHS[\n                  Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)\n                ];\n        }\n        setText(s);\n        tick++;\n      },\n      (speed / SCRAMBLE_TARGET.length) * 1000 * 0.55,\n    );\n    return () => clearInterval(id);\n  }, [speed, reduce]);\n\n  return (\n    <span\n      className=\"font-mono font-medium tracking-[0.2em] tabular-nums\"\n      style={{ fontSize: size * 0.42 }}\n    >\n      {text}\n    </span>\n  );\n}\n\nfunction Metaballs({ size, speed, reduce }: PartProps) {\n  const id = useId().replace(/:/g, \"\");\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 100 100\" role=\"img\">\n      <title>Loading</title>\n      <defs>\n        <filter id={id}>\n          <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"5\" result=\"b\" />\n          <feColorMatrix\n            in=\"b\"\n            values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -8\"\n          />\n        </filter>\n      </defs>\n      <g filter={`url(#${id})`} fill=\"currentColor\">\n        <motion.circle\n          cy=\"50\"\n          r=\"15\"\n          animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [30, 70, 30] }}\n          transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}\n          cx={reduce ? 40 : 30}\n        />\n        <motion.circle\n          cy=\"50\"\n          r=\"15\"\n          animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [70, 30, 70] }}\n          transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}\n          cx={reduce ? 60 : 70}\n        />\n      </g>\n    </svg>\n  );\n}\n\nconst NEWTON_BALLS = [0, 1, 2, 3, 4];\n\nfunction Newton({ size, speed, reduce }: PartProps) {\n  const d = size * 0.2;\n  const out = d * 1.1;\n  // Only the end balls move: the left slides out and back on the first half,\n  // then the right on the second half — the impact appears to jump the three\n  // still middle balls. Pure horizontal slide, no swing, no strings.\n  const moves: Record<number, { x: number[]; times: number[] }> = {\n    0: { x: [0, -out, 0, 0], times: [0, 0.28, 0.5, 1] },\n    4: { x: [0, 0, out, 0], times: [0, 0.5, 0.78, 1] },\n  };\n\n  return (\n    <span className=\"flex items-center justify-center\" style={{ height: d }}>\n      {NEWTON_BALLS.map((i) => {\n        const move = moves[i];\n        return (\n          <motion.span\n            key={i}\n            className=\"rounded-full bg-current\"\n            style={{ width: d, height: d }}\n            animate={reduce || !move ? undefined : { x: move.x }}\n            transition={\n              reduce || !move\n                ? undefined\n                : {\n                    duration: speed * 1.5,\n                    ease: EASE_IN_OUT,\n                    repeat: Infinity,\n                    times: move.times,\n                  }\n            }\n          />\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Helix({ size, speed, reduce }: PartProps) {\n  const rows = 7;\n  const dot = size * 0.14;\n  const amp = size * 0.32;\n  return (\n    <span className=\"relative\" style={{ width: size, height: size }}>\n      {Array.from({ length: rows }, (_, r) => {\n        const top = (r / (rows - 1)) * (size - dot);\n        const delay = (r / rows) * speed;\n        return (\n          <span key={`row-${top}`}>\n            <motion.span\n              className=\"absolute rounded-full bg-current\"\n              style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}\n              animate={\n                reduce\n                  ? { opacity: [0.4, 1, 0.4] }\n                  : {\n                      x: [amp, -amp, amp],\n                      scale: [1, 0.5, 1],\n                      opacity: [1, 0.45, 1],\n                    }\n              }\n              transition={{\n                duration: speed,\n                ease: EASE_IN_OUT,\n                repeat: Infinity,\n                delay,\n              }}\n            />\n            <motion.span\n              className=\"absolute rounded-full bg-current\"\n              style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}\n              animate={\n                reduce\n                  ? { opacity: [0.4, 1, 0.4] }\n                  : {\n                      x: [-amp, amp, -amp],\n                      scale: [0.5, 1, 0.5],\n                      opacity: [0.45, 1, 0.45],\n                    }\n              }\n              transition={{\n                duration: speed,\n                ease: EASE_IN_OUT,\n                repeat: Infinity,\n                delay,\n              }}\n            />\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Percent({ size, speed, reduce }: PartProps) {\n  const [p, setP] = useState(0);\n  useEffect(() => {\n    const dur = (reduce ? speed * 2 : speed) * 1000;\n    const start = { t: 0 };\n    const tickMs = 40;\n    const id = setInterval(() => {\n      start.t += tickMs;\n      const next = Math.min(100, Math.round((start.t / dur) * 100));\n      setP(next);\n      if (next >= 100) start.t = 0;\n    }, tickMs);\n    return () => clearInterval(id);\n  }, [speed, reduce]);\n\n  return (\n    <span\n      className=\"flex flex-col items-center\"\n      style={{ gap: size * 0.14, width: size * 1.4 }}\n    >\n      <span\n        className=\"font-mono font-medium tabular-nums\"\n        style={{ fontSize: size * 0.42, lineHeight: 1 }}\n      >\n        {p}%\n      </span>\n      <span\n        className=\"w-full overflow-hidden rounded-full bg-current/15\"\n        style={{ height: Math.max(3, size * 0.1) }}\n      >\n        <span\n          className=\"block h-full rounded-full bg-current\"\n          style={{ width: `${p}%` }}\n        />\n      </span>\n    </span>\n  );\n}\n\nfunction Bars({ size, speed, reduce }: PartProps) {\n  const bar = size * 0.16;\n  return (\n    <span className=\"flex items-center\" style={{ gap: size * 0.1, height: size }}>\n      {[0, 1, 2, 3].map((i) => (\n        <motion.span\n          key={i}\n          className=\"rounded-full bg-current\"\n          style={{ width: bar, height: size, originY: 1 }}\n          animate={\n            reduce ? { opacity: [0.4, 1, 0.4] } : { scaleY: [0.3, 1, 0.3] }\n          }\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: i * speed * 0.12,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction DotMatrix({ size, speed, reduce }: PartProps) {\n  const n = 3;\n  const gap = size * 0.14;\n  const dot = (size - gap * (n - 1)) / n;\n  const cells = Array.from({ length: n * n }, (_, idx) => idx);\n  return (\n    <span\n      className=\"grid\"\n      style={{\n        gap,\n        gridTemplateColumns: `repeat(${n}, ${dot}px)`,\n      }}\n    >\n      {cells.map((idx) => {\n        const x = idx % n;\n        const y = Math.floor(idx / n);\n        // Diagonal wave: cells light in order of their distance from the corner.\n        const delay = ((x + y) / (2 * (n - 1))) * speed;\n        return (\n          <motion.span\n            key={idx}\n            className=\"rounded-full bg-current\"\n            style={{ width: dot, height: dot }}\n            animate={\n              reduce\n                ? { opacity: [0.3, 1, 0.3] }\n                : { opacity: [0.2, 1, 0.2], scale: [0.7, 1, 0.7] }\n            }\n            transition={{\n              duration: speed,\n              ease: EASE_IN_OUT,\n              repeat: Infinity,\n              delay,\n            }}\n          />\n        );\n      })}\n    </span>\n  );\n}\n\n// Ordered Bayer 4x4 matrix — the classic dithering threshold pattern. Cells\n// light in this order, so the fill shimmers like a dissolving halftone.\nconst BAYER_4 = [\n  0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,\n];\n\nfunction Dither({ size, speed, reduce }: PartProps) {\n  const n = 4;\n  const gap = Math.max(1, size * 0.05);\n  const cell = (size - gap * (n - 1)) / n;\n  return (\n    <span\n      className=\"grid\"\n      style={{ gap, gridTemplateColumns: `repeat(${n}, ${cell}px)` }}\n    >\n      {BAYER_4.map((order, idx) => (\n        <motion.span\n          // biome-ignore lint/suspicious/noArrayIndexKey: fixed matrix cells, order never changes\n          key={idx}\n          className=\"bg-current\"\n          style={{ width: cell, height: cell }}\n          animate={reduce ? { opacity: [0.3, 1, 0.3] } : { opacity: [0.1, 1, 0.1] }}\n          transition={{\n            duration: speed,\n            ease: EASE_IN_OUT,\n            repeat: Infinity,\n            delay: (order / BAYER_4.length) * speed,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n"},{"path":"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":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"components/motion/magnetic.tsx","type":"registry:component","target":"@components/motion/magnetic.tsx","content":"\"use client\";\n\nimport { motion, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { useRef, type ReactNode } from \"react\";\nimport { SPRING_MOUSE } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface MagneticProps {\n  children: ReactNode;\n  strength?: number;\n  className?: string;\n}\n\nexport function Magnetic({ children, strength = 0.35, className }: MagneticProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  // Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.\n  const enabled = !reduce && canHover;\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, SPRING_MOUSE);\n  const sy = useSpring(y, SPRING_MOUSE);\n\n  const onMove = (e: React.MouseEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !enabled) return;\n    const rect = el.getBoundingClientRect();\n    x.set((e.clientX - rect.left - rect.width / 2) * strength);\n    y.set((e.clientY - rect.top - rect.height / 2) * strength);\n  };\n\n  const onLeave = () => {\n    x.set(0);\n    y.set(0);\n  };\n\n  return (\n    <motion.div\n      ref={ref}\n      onMouseMove={onMove}\n      onMouseLeave={onLeave}\n      style={{ x: sx, y: sy }}\n      className={cn(\"inline-block\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"}]}