{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"model-selector","type":"registry:component","title":"Model Selector","description":"Searchable AI model picker (`ModelSelector`) that morphs open from its trigger with models grouped by provider, generic geometric provider glyphs, vision/tools/reasoning/fast capability badges, context size, relative cost dots, a New tag and a gliding active row with full keyboard navigation; a richer standalone alternative to PromptInput's compact model select, controlled or uncontrolled.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/model-selector.tsx","type":"registry:component","target":"@components/agents/model-selector.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/agents/model-selector\n\nimport { Brain, Check, ChevronDown, Eye, Search, Wrench, Zap } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Badge } from \"@/components/motion/badge\";\nimport { Kbd } from \"@/components/motion/kbd\";\nimport {\n  MorphPopover,\n  MorphPopoverContent,\n  MorphPopoverTrigger,\n} from \"@/components/motion/popover-morph\";\nimport { ScrollArea } from \"@/components/motion/scroll-area\";\nimport { SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ModelCapability = \"vision\" | \"tools\" | \"reasoning\" | \"fast\";\n\nexport type ProviderGlyphShape =\n  | \"circle\"\n  | \"square\"\n  | \"triangle\"\n  | \"hexagon\"\n  | \"diamond\"\n  | \"spark\";\n\nexport interface ModelProvider {\n  id: string;\n  name: string;\n  /** Generic geometric glyph. Defaults by provider order. */\n  shape?: ProviderGlyphShape;\n  /** Replaces the generic glyph entirely (e.g. your own logo). */\n  icon?: ReactNode;\n}\n\nexport interface ModelOption {\n  value: string;\n  name: string;\n  /** `ModelProvider.id` the model is grouped under. */\n  provider: string;\n  description?: string;\n  capabilities?: ModelCapability[];\n  /** Context window in tokens. */\n  contextWindow?: number;\n  /** Relative cost from 1 (cheapest) to 4. */\n  cost?: 1 | 2 | 3 | 4;\n  isNew?: boolean;\n  disabled?: boolean;\n}\n\nexport interface ModelSelectorProps {\n  models: ModelOption[];\n  /** Provider order and naming. Providers missing here are derived from `models`. */\n  providers?: ModelProvider[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  placeholder?: string;\n  searchPlaceholder?: string;\n  emptyMessage?: ReactNode;\n  side?: \"top\" | \"bottom\";\n  align?: \"start\" | \"end\";\n  disabled?: boolean;\n  /** Hide the keyboard hint footer. */\n  hideHints?: boolean;\n  className?: string;\n  triggerClassName?: string;\n  contentClassName?: string;\n  \"aria-label\"?: string;\n}\n\nconst SHAPES: ProviderGlyphShape[] = [\n  \"spark\",\n  \"hexagon\",\n  \"circle\",\n  \"diamond\",\n  \"square\",\n  \"triangle\",\n];\n\nconst CAPABILITIES: Record<\n  ModelCapability,\n  { label: string; icon: ReactNode }\n> = {\n  vision: { label: \"Vision\", icon: <Eye /> },\n  tools: { label: \"Tools\", icon: <Wrench /> },\n  reasoning: { label: \"Reasoning\", icon: <Brain /> },\n  fast: { label: \"Fast\", icon: <Zap /> },\n};\n\n/** 200000 → \"200k\", 1048576 → \"1M\". */\nexport function formatContextWindow(tokens: number) {\n  if (tokens >= 1_000_000) {\n    const m = tokens / 1_000_000;\n    return `${Number.isInteger(m) ? m : m.toFixed(1)}M`;\n  }\n  if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`;\n  return String(tokens);\n}\n\nfunction GlyphShape({ shape }: { shape: ProviderGlyphShape }) {\n  switch (shape) {\n    case \"circle\":\n      return <circle cx=\"8\" cy=\"8\" r=\"5.25\" />;\n    case \"square\":\n      return <rect x=\"2.75\" y=\"2.75\" width=\"10.5\" height=\"10.5\" rx=\"2.5\" />;\n    case \"triangle\":\n      return <path d=\"M8 2.4 13.9 12.9H2.1Z\" />;\n    case \"hexagon\":\n      return <path d=\"M8 1.9 13.3 4.95V11.05L8 14.1 2.7 11.05V4.95Z\" />;\n    case \"diamond\":\n      return <path d=\"M8 1.6 14.4 8 8 14.4 1.6 8Z\" />;\n    default:\n      return (\n        <path d=\"M8 1.5C8.6 5.6 10.4 7.4 14.5 8 10.4 8.6 8.6 10.4 8 14.5 7.4 10.4 5.6 8.6 1.5 8 5.6 7.4 7.4 5.6 8 1.5Z\" />\n      );\n  }\n}\n\n/** Generic provider mark: a geometric shape on a tinted tile, colored by `--chart-*`. */\nexport function ProviderGlyph({\n  provider,\n  index = 0,\n  className,\n}: {\n  provider?: ModelProvider;\n  index?: number;\n  className?: string;\n}) {\n  const shape = provider?.shape ?? SHAPES[index % SHAPES.length];\n  return (\n    <span\n      aria-hidden=\"true\"\n      style={{ color: `var(--chart-${(index % 5) + 1})` }}\n      className={cn(\n        \"grid size-7 shrink-0 place-items-center rounded-lg bg-current/12 [&_svg]:size-4\",\n        className,\n      )}\n    >\n      {provider?.icon ?? (\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 16 16\"\n          fill=\"currentColor\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1\"\n          strokeLinejoin=\"round\"\n        >\n          <GlyphShape shape={shape} />\n        </svg>\n      )}\n    </span>\n  );\n}\n\nfunction CostDots({ cost }: { cost: number }) {\n  return (\n    <span className=\"flex items-center gap-0.5\">\n      <span className=\"sr-only\">Relative cost {cost} of 4</span>\n      {[1, 2, 3, 4].map((dot) => (\n        <span\n          key={dot}\n          aria-hidden=\"true\"\n          className={cn(\n            \"size-1 rounded-full\",\n            dot <= cost ? \"bg-foreground/70\" : \"bg-foreground/15\",\n          )}\n        />\n      ))}\n    </span>\n  );\n}\n\nconst optionId = (uid: string, value: string) =>\n  `${uid}-opt-${value.replace(/\\W/g, \"_\")}`;\n\nfunction matches(model: ModelOption, providerName: string, query: string) {\n  const haystack = [\n    model.name,\n    providerName,\n    model.description ?? \"\",\n    ...(model.capabilities ?? []),\n  ]\n    .join(\" \")\n    .toLowerCase();\n  return query\n    .toLowerCase()\n    .split(/\\s+/)\n    .filter(Boolean)\n    .every((token) => haystack.includes(token));\n}\n\nexport function ModelSelector({\n  models,\n  providers,\n  value,\n  defaultValue,\n  onValueChange,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  placeholder = \"Choose a model\",\n  searchPlaceholder = \"Search models…\",\n  emptyMessage = \"No models match your search.\",\n  side = \"bottom\",\n  align = \"start\",\n  disabled = false,\n  hideHints = false,\n  className,\n  triggerClassName,\n  contentClassName,\n  \"aria-label\": ariaLabel = \"Model\",\n}: ModelSelectorProps) {\n  const reduce = useReducedMotion() ?? false;\n  const uid = useId().replace(/:/g, \"\");\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const current = value ?? internalValue;\n  const isOpen = open ?? internalOpen;\n\n  // Provider order: explicit list first, then any provider only seen in models.\n  const providerList = useMemo(() => {\n    const list = [...(providers ?? [])];\n    for (const model of models) {\n      if (!list.some((provider) => provider.id === model.provider)) {\n        list.push({ id: model.provider, name: model.provider });\n      }\n    }\n    return list;\n  }, [models, providers]);\n\n  const providerIndex = (id: string) =>\n    Math.max(\n      0,\n      providerList.findIndex((provider) => provider.id === id),\n    );\n\n  const selected = models.find((model) => model.value === current);\n\n  const setOpen = (next: boolean) => {\n    if (open === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n\n  const select = (model: ModelOption) => {\n    if (model.disabled) return;\n    if (value === undefined) setInternalValue(model.value);\n    onValueChange?.(model.value);\n    setOpen(false);\n    triggerRef.current?.focus({ preventScroll: true });\n  };\n\n  return (\n    <MorphPopover\n      open={isOpen}\n      onOpenChange={setOpen}\n      className={cn(\"max-w-full\", className)}\n    >\n      <MorphPopoverTrigger>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        disabled={disabled}\n        aria-label={`${ariaLabel}: ${selected?.name ?? placeholder}`}\n        className={cn(\n          \"inline-flex h-9 max-w-full items-center gap-2 rounded-xl border border-border bg-background pr-2 pl-1 text-sm text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n          triggerClassName,\n        )}\n      >\n        {selected ? (\n          <ProviderGlyph\n            provider={providerList[providerIndex(selected.provider)]}\n            index={providerIndex(selected.provider)}\n            className=\"size-7 [&_svg]:size-3.5\"\n          />\n        ) : null}\n        <span\n          className={cn(\n            \"min-w-0 truncate\",\n            !selected && \"pl-2 text-muted-foreground\",\n          )}\n        >\n          {selected?.name ?? placeholder}\n        </span>\n        <motion.span\n          aria-hidden=\"true\"\n          animate={{ rotate: isOpen ? 180 : 0 }}\n          transition={reduce ? { duration: 0 } : SPRING_SWAP}\n          className=\"grid shrink-0 place-items-center text-muted-foreground\"\n        >\n          <ChevronDown className=\"size-3.5\" />\n        </motion.span>\n      </button>\n      </MorphPopoverTrigger>\n\n      <MorphPopoverContent\n        side={side}\n        align={align}\n        radius={14}\n        className={cn(\"w-[min(22rem,calc(100vw-2rem))]\", contentClassName)}\n      >\n        <ModelList\n          uid={uid}\n          models={models}\n          providerList={providerList}\n          providerIndex={providerIndex}\n          current={current}\n          reduce={reduce}\n          searchPlaceholder={searchPlaceholder}\n          emptyMessage={emptyMessage}\n          hideHints={hideHints}\n          ariaLabel={ariaLabel}\n          onSelect={select}\n        />\n      </MorphPopoverContent>\n    </MorphPopover>\n  );\n}\n\nfunction ModelList({\n  uid,\n  models,\n  providerList,\n  providerIndex,\n  current,\n  reduce,\n  searchPlaceholder,\n  emptyMessage,\n  hideHints,\n  ariaLabel,\n  onSelect,\n}: {\n  uid: string;\n  models: ModelOption[];\n  providerList: ModelProvider[];\n  providerIndex: (id: string) => number;\n  current: string | undefined;\n  reduce: boolean;\n  searchPlaceholder: string;\n  emptyMessage: ReactNode;\n  hideHints: boolean;\n  ariaLabel: string;\n  onSelect: (model: ModelOption) => void;\n}) {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [query, setQuery] = useState(\"\");\n  const listId = `${uid}-list`;\n\n  const groups = useMemo(\n    () =>\n      providerList\n        .map((provider) => ({\n          provider,\n          models: models.filter(\n            (model) =>\n              model.provider === provider.id &&\n              matches(model, provider.name, query),\n          ),\n        }))\n        .filter((group) => group.models.length > 0),\n    [models, providerList, query],\n  );\n\n  const enabled = useMemo(\n    () =>\n      groups.flatMap((group) => group.models).filter((model) => !model.disabled),\n    [groups],\n  );\n\n  const [active, setActive] = useState<string | undefined>(() =>\n    enabled.some((model) => model.value === current)\n      ? current\n      : enabled[0]?.value,\n  );\n\n  // Keep the active row valid as the filter narrows the list.\n  const activeValue = enabled.some((model) => model.value === active)\n    ? active\n    : enabled[0]?.value;\n\n  // The panel mounts with the popover; hand focus to the search field once it\n  // is laid out (the morph keeps it hidden until measured).\n  useEffect(() => {\n    const frame = requestAnimationFrame(() =>\n      inputRef.current?.focus({ preventScroll: true }),\n    );\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  useEffect(() => {\n    if (!activeValue) return;\n    document\n      .getElementById(optionId(uid, activeValue))\n      ?.scrollIntoView({ block: \"nearest\" });\n  }, [activeValue, uid]);\n\n  const move = (delta: number) => {\n    if (!enabled.length) return;\n    const index = enabled.findIndex((model) => model.value === activeValue);\n    const next = (index + delta + enabled.length) % enabled.length;\n    setActive(enabled[next].value);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      move(1);\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      move(-1);\n    } else if (event.key === \"PageDown\" || (event.key === \"End\" && event.ctrlKey)) {\n      event.preventDefault();\n      setActive(enabled.at(-1)?.value);\n    } else if (event.key === \"PageUp\" || (event.key === \"Home\" && event.ctrlKey)) {\n      event.preventDefault();\n      setActive(enabled[0]?.value);\n    } else if (event.key === \"Enter\" && !event.nativeEvent.isComposing) {\n      event.preventDefault();\n      const model = enabled.find((item) => item.value === activeValue);\n      if (model) onSelect(model);\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col\">\n      <div className=\"flex items-center gap-2 border-b border-border px-3\">\n        <Search aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n        <input\n          ref={inputRef}\n          type=\"text\"\n          role=\"combobox\"\n          aria-label={`Search ${ariaLabel.toLowerCase()}s`}\n          aria-expanded=\"true\"\n          aria-controls={listId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={activeValue ? optionId(uid, activeValue) : undefined}\n          value={query}\n          placeholder={searchPlaceholder}\n          onChange={(event) => setQuery(event.target.value)}\n          onKeyDown={onKeyDown}\n          className=\"h-10 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/60\"\n        />\n      </div>\n\n      <ScrollArea\n        aria-label={`${ariaLabel} list`}\n        viewportClassName=\"max-h-[17.5rem]\"\n        fadeEdges\n      >\n        <div\n          id={listId}\n          role=\"listbox\"\n          aria-label={ariaLabel}\n          className=\"relative isolate p-1.5\"\n        >\n          {groups.map((group) => {\n            const index = providerIndex(group.provider.id);\n            return (\n              <fieldset key={group.provider.id} className=\"m-0 min-w-0 border-0 p-0 pb-1\">\n                <legend className=\"w-full px-2 pt-1.5 pb-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted-foreground\">\n                  {group.provider.name}\n                </legend>\n                {group.models.map((model) => {\n                  const isActive = model.value === activeValue;\n                  const isSelected = model.value === current;\n                  return (\n                    <button\n                      key={model.value}\n                      id={optionId(uid, model.value)}\n                      type=\"button\"\n                      role=\"option\"\n                      tabIndex={-1}\n                      aria-selected={isSelected}\n                      aria-disabled={model.disabled || undefined}\n                      onMouseDown={(event) => event.preventDefault()}\n                      onPointerMove={() => {\n                        if (!model.disabled && !isActive) setActive(model.value);\n                      }}\n                      onClick={() => onSelect(model)}\n                      className={cn(\n                        \"relative flex w-full items-start gap-2.5 rounded-lg px-2 py-2 text-left outline-none\",\n                        model.disabled && \"cursor-not-allowed opacity-45\",\n                      )}\n                    >\n                      {isActive ? (\n                        <motion.span\n                          aria-hidden=\"true\"\n                          layoutId={`${uid}-active`}\n                          transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                          className=\"absolute inset-0 -z-10 rounded-lg bg-muted\"\n                        />\n                      ) : null}\n                      <ProviderGlyph provider={group.provider} index={index} />\n                      <span className=\"min-w-0 flex-1\">\n                        <span className=\"flex items-center gap-1.5\">\n                          <span className=\"truncate text-sm font-medium text-foreground\">\n                            {model.name}\n                          </span>\n                          {model.isNew ? (\n                            <Badge size=\"sm\" variant=\"default\" className=\"h-4 px-1.5 text-[10px]\">\n                              New\n                            </Badge>\n                          ) : null}\n                        </span>\n                        {model.description ? (\n                          <span className=\"mt-0.5 block truncate text-xs text-muted-foreground\">\n                            {model.description}\n                          </span>\n                        ) : null}\n                        {model.capabilities?.length ? (\n                          <span className=\"mt-1.5 flex flex-wrap gap-1\">\n                            {model.capabilities.map((capability) => (\n                              <Badge\n                                key={capability}\n                                size=\"sm\"\n                                variant=\"secondary\"\n                                icon={CAPABILITIES[capability].icon}\n                                className=\"h-[18px] font-normal text-muted-foreground\"\n                              >\n                                {CAPABILITIES[capability].label}\n                              </Badge>\n                            ))}\n                          </span>\n                        ) : null}\n                      </span>\n                      <span className=\"flex shrink-0 flex-col items-end gap-1.5 pt-0.5\">\n                        {model.contextWindow ? (\n                          <span className=\"font-mono text-[11px] tabular-nums text-muted-foreground\">\n                            <span className=\"sr-only\">Context window </span>\n                            {formatContextWindow(model.contextWindow)}\n                          </span>\n                        ) : null}\n                        {model.cost ? <CostDots cost={model.cost} /> : null}\n                      </span>\n                      <span className=\"grid size-4 shrink-0 place-items-center self-center text-foreground\">\n                        {isSelected ? (\n                          <motion.span\n                            initial={reduce ? false : { opacity: 0, scale: 0.6 }}\n                            animate={{ opacity: 1, scale: 1 }}\n                            transition={reduce ? { duration: 0 } : SPRING_SWAP}\n                          >\n                            <Check aria-hidden=\"true\" className=\"size-4\" />\n                          </motion.span>\n                        ) : null}\n                      </span>\n                    </button>\n                  );\n                })}\n              </fieldset>\n            );\n          })}\n          {groups.length === 0 ? (\n            <p className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n              {emptyMessage}\n            </p>\n          ) : null}\n        </div>\n      </ScrollArea>\n\n      {hideHints ? null : (\n        <div className=\"flex items-center gap-3 border-t border-border px-3 py-2 text-[11px] text-muted-foreground\">\n          <span className=\"flex items-center gap-1\">\n            <Kbd size=\"sm\">up</Kbd>\n            <Kbd size=\"sm\">down</Kbd>\n            Navigate\n          </span>\n          <span className=\"flex items-center gap-1\">\n            <Kbd size=\"sm\">enter</Kbd>\n            Select\n          </span>\n          <span className=\"ml-auto flex items-center gap-1\">\n            <Kbd size=\"sm\">esc</Kbd>\n            Close\n          </span>\n        </div>\n      )}\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/kbd.tsx","type":"registry:component","target":"@components/motion/kbd.tsx","content":"\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, type ReactNode, useEffect, useState } from \"react\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                                  Platform                                  */\n/* -------------------------------------------------------------------------- */\n\nfunction detectApple() {\n  const nav = navigator as Navigator & {\n    userAgentData?: { platform?: string };\n  };\n  const platform = nav.userAgentData?.platform ?? nav.platform ?? \"\";\n  return /mac|iphone|ipad|ipod/i.test(platform);\n}\n\n/**\n * True on Apple platforms. Always `true` on the server and the first client\n * render (so markup matches), then corrected after mount — `Mod` therefore\n * renders ⌘ first and swaps to Ctrl on other platforms.\n */\nexport function useIsApple() {\n  const [apple, setApple] = useState(true);\n  useEffect(() => setApple(detectApple()), []);\n  return apple;\n}\n\ntype KeyLabel = { glyph: string; name: string };\n\n/** Display glyph + spoken name for a key token like \"mod\", \"shift\", \"k\". */\nexport function keyLabel(key: string, apple: boolean): KeyLabel {\n  const k = key.toLowerCase();\n  switch (k) {\n    case \"mod\":\n      return apple\n        ? { glyph: \"⌘\", name: \"Command\" }\n        : { glyph: \"Ctrl\", name: \"Control\" };\n    case \"cmd\":\n    case \"command\":\n    case \"meta\":\n      return apple\n        ? { glyph: \"⌘\", name: \"Command\" }\n        : { glyph: \"Win\", name: \"Windows\" };\n    case \"ctrl\":\n    case \"control\":\n      return apple\n        ? { glyph: \"⌃\", name: \"Control\" }\n        : { glyph: \"Ctrl\", name: \"Control\" };\n    case \"shift\":\n      return apple\n        ? { glyph: \"⇧\", name: \"Shift\" }\n        : { glyph: \"Shift\", name: \"Shift\" };\n    case \"alt\":\n    case \"option\":\n      return apple\n        ? { glyph: \"⌥\", name: \"Option\" }\n        : { glyph: \"Alt\", name: \"Alt\" };\n    case \"enter\":\n    case \"return\":\n      return { glyph: \"↵\", name: \"Enter\" };\n    case \"esc\":\n    case \"escape\":\n      return { glyph: \"Esc\", name: \"Escape\" };\n    case \"backspace\":\n      return { glyph: \"⌫\", name: \"Backspace\" };\n    case \"delete\":\n      return { glyph: \"⌦\", name: \"Delete\" };\n    case \"tab\":\n      return { glyph: \"⇥\", name: \"Tab\" };\n    case \"space\":\n      return { glyph: \"Space\", name: \"Space\" };\n    case \"up\":\n    case \"arrowup\":\n      return { glyph: \"↑\", name: \"Up arrow\" };\n    case \"down\":\n    case \"arrowdown\":\n      return { glyph: \"↓\", name: \"Down arrow\" };\n    case \"left\":\n    case \"arrowleft\":\n      return { glyph: \"←\", name: \"Left arrow\" };\n    case \"right\":\n    case \"arrowright\":\n      return { glyph: \"→\", name: \"Right arrow\" };\n    default:\n      return key.length === 1\n        ? { glyph: key.toUpperCase(), name: key.toUpperCase() }\n        : { glyph: key, name: key };\n  }\n}\n\n/* -------------------------------------------------------------------------- */\n/*                               Key listening                                */\n/* -------------------------------------------------------------------------- */\n\n/** Does this keyboard event's key correspond to the token? */\nfunction eventMatches(token: string, event: KeyboardEvent, apple: boolean) {\n  const k = token.toLowerCase();\n  const key = event.key.toLowerCase();\n  switch (k) {\n    case \"mod\":\n      return key === (apple ? \"meta\" : \"control\");\n    case \"cmd\":\n    case \"command\":\n    case \"meta\":\n      return key === \"meta\";\n    case \"ctrl\":\n    case \"control\":\n      return key === \"control\";\n    case \"alt\":\n    case \"option\":\n      return key === \"alt\";\n    case \"esc\":\n      return key === \"escape\";\n    case \"return\":\n      return key === \"enter\";\n    case \"space\":\n      return event.code === \"Space\";\n    case \"up\":\n    case \"down\":\n    case \"left\":\n    case \"right\":\n      return key === `arrow${k}`;\n    default:\n      // Letters and digits by physical key, so Shift/Option variants\n      // (\"P\" with Shift, \"π\" with Option) still match.\n      if (/^[a-z]$/.test(k)) return event.code === `Key${k.toUpperCase()}`;\n      if (/^[0-9]$/.test(k)) return event.code === `Digit${k}`;\n      return key === k;\n  }\n}\n\nconst MODIFIERS = new Set([\"meta\", \"control\", \"shift\", \"alt\"]);\n\n/**\n * Tracks which of `keys` are physically held. Listens on window without\n * preventing default, so it never steals the shortcut from the page.\n */\nexport function useKeysPressed(keys: string[], enabled = true) {\n  const apple = useIsApple();\n  const [held, setHeld] = useState<boolean[]>(() => keys.map(() => false));\n  const signature = keys.join(\"+\");\n\n  useEffect(() => {\n    const tokens = signature.split(\"+\");\n    setHeld(tokens.map(() => false));\n    if (!enabled) return;\n    const down = new Set<number>();\n    const sync = () => setHeld(tokens.map((_, i) => down.has(i)));\n\n    const onDown = (event: KeyboardEvent) => {\n      let changed = false;\n      tokens.forEach((token, i) => {\n        if (!down.has(i) && eventMatches(token, event, apple)) {\n          down.add(i);\n          changed = true;\n        }\n      });\n      if (changed) sync();\n    };\n    const onUp = (event: KeyboardEvent) => {\n      // macOS swallows keyup for other keys while ⌘ is held, so releasing a\n      // modifier releases everything.\n      if (MODIFIERS.has(event.key.toLowerCase()) && apple) {\n        down.clear();\n        sync();\n        return;\n      }\n      let changed = false;\n      tokens.forEach((token, i) => {\n        if (down.has(i) && eventMatches(token, event, apple)) {\n          down.delete(i);\n          changed = true;\n        }\n      });\n      if (changed) sync();\n    };\n    const reset = () => {\n      down.clear();\n      sync();\n    };\n    window.addEventListener(\"keydown\", onDown);\n    window.addEventListener(\"keyup\", onUp);\n    window.addEventListener(\"blur\", reset);\n    return () => {\n      window.removeEventListener(\"keydown\", onDown);\n      window.removeEventListener(\"keyup\", onUp);\n      window.removeEventListener(\"blur\", reset);\n    };\n  }, [signature, enabled, apple]);\n\n  return held;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                    Kbd                                     */\n/* -------------------------------------------------------------------------- */\n\ntype KbdSize = \"sm\" | \"md\";\n\nconst SIZE: Record<KbdSize, string> = {\n  sm: \"h-5 min-w-5 px-1 text-[11px] rounded-[5px] border-b-[2px]\",\n  md: \"h-6 min-w-6 px-1.5 text-xs rounded-md border-b-[3px]\",\n};\n\n// How far the key drops when pressed, in px — matches the thicker bottom edge\n// so the cap visibly sinks into its base.\nconst DIP: Record<KbdSize, number> = { sm: 1.5, md: 2 };\n\nexport interface KbdProps {\n  /** A key token (\"mod\", \"shift\", \"k\", \"enter\"…) or any custom content. */\n  children: ReactNode;\n  size?: KbdSize;\n  /** Show the key pressed (spring dip). */\n  pressed?: boolean;\n  /** Dip when the real key is held. Needs `children` to be a key token string. */\n  listen?: boolean;\n  className?: string;\n}\n\n/**\n * A single keyboard key. String children are treated as key tokens, so\n * `<Kbd>mod</Kbd>` renders ⌘ on Apple platforms and Ctrl elsewhere.\n */\nexport function Kbd({\n  children,\n  size = \"md\",\n  pressed = false,\n  listen = false,\n  className,\n}: KbdProps) {\n  const apple = useIsApple();\n  const token = typeof children === \"string\" ? children : null;\n  const [held] = useKeysPressed(token ? [token] : [], listen && token !== null);\n  const label = token ? keyLabel(token, apple) : null;\n\n  return (\n    <KeyCap\n      size={size}\n      pressed={pressed || Boolean(held)}\n      title={label?.name}\n      className={className}\n    >\n      {label ? label.glyph : children}\n    </KeyCap>\n  );\n}\n\nfunction KeyCap({\n  children,\n  size,\n  pressed,\n  title,\n  className,\n}: {\n  children: ReactNode;\n  size: KbdSize;\n  pressed: boolean;\n  title?: string;\n  className?: string;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  return (\n    <motion.kbd\n      title={title}\n      data-pressed={pressed ? \"\" : undefined}\n      initial={false}\n      animate={\n        reduce\n          ? { opacity: pressed ? 0.7 : 1 }\n          : { y: pressed ? DIP[size] : 0, scale: pressed ? 0.96 : 1 }\n      }\n      transition={reduce ? { duration: 0.1 } : SPRING_PRESS}\n      className={cn(\n        \"inline-flex select-none items-center justify-center border border-border border-b-border-strong bg-muted font-sans font-medium leading-none text-muted-foreground\",\n        \"data-[pressed]:border-b-border data-[pressed]:bg-foreground/[0.08] data-[pressed]:text-foreground\",\n        SIZE[size],\n        className,\n      )}\n    >\n      {children}\n    </motion.kbd>\n  );\n}\n\nexport interface KbdComboProps {\n  /** Key tokens, e.g. [\"mod\", \"shift\", \"p\"]. */\n  keys: string[];\n  size?: KbdSize;\n  /** Show every key pressed. */\n  pressed?: boolean;\n  /** Dip each key while the real key is held. */\n  listen?: boolean;\n  /** Text between keys. Default none on Apple, \"+\" elsewhere. */\n  separator?: ReactNode;\n  className?: string;\n}\n\n/**\n * A key combination: one chip per key, platform-aware, with a spoken label\n * (\"Command + Shift + P\") for assistive tech.\n */\nexport function KbdCombo({\n  keys,\n  size = \"md\",\n  pressed = false,\n  listen = false,\n  separator,\n  className,\n}: KbdComboProps) {\n  const apple = useIsApple();\n  const held = useKeysPressed(keys, listen);\n  const labels = keys.map((key) => keyLabel(key, apple));\n  const sep = separator === undefined ? (apple ? null : \"+\") : separator;\n\n  return (\n    <kbd\n      className={cn(\n        \"inline-flex items-center gap-1 font-sans text-muted-foreground\",\n        className,\n      )}\n    >\n      <span className=\"sr-only\">{labels.map((l) => l.name).join(\" + \")}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center gap-1\">\n        {labels.map((label, i) => (\n          <Fragment key={keys[i]}>\n            {i > 0 && sep !== null ? (\n              <span className=\"text-[10px] text-muted-foreground/70\">{sep}</span>\n            ) : null}\n            <KeyCap size={size} pressed={pressed || Boolean(held[i])}>\n              {label.glyph}\n            </KeyCap>\n          </Fragment>\n        ))}\n      </span>\n    </kbd>\n  );\n}\n"},{"path":"components/motion/popover-morph.tsx","type":"registry:component","target":"@components/motion/popover-morph.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"end\";\n\ntype MorphContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  toggle: () => void;\n  triggerId: string;\n  contentId: string;\n  /** The element the panel measures against — see `registerTrigger`. */\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  registerTrigger: (node: HTMLElement | null) => void;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\n};\n\nconst MorphContext = createContext<MorphContextValue | null>(null);\n\nfunction useMorphContext(component: string) {\n  const ctx = useContext(MorphContext);\n  if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);\n  return ctx;\n}\n\nexport interface MorphPopoverProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\n/**\n * A popover whose panel morphs open from the trigger corner: it's laid out at\n * full size but clipped to the corner nearest the trigger, then unclips as one\n * piece. Closes on outside pointer / Escape. Controlled or uncontrolled.\n */\nexport function MorphPopover({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: MorphPopoverProps) {\n  const baseId = useId();\n  const [root, setRoot] = useState<HTMLDivElement | null>(null);\n  const [trigger, setTrigger] = useState<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);\n\n  // A trigger normally registers itself through MorphPopoverTrigger. It can't\n  // when something else already clones the element — a Tooltip wrapping the\n  // button, say — and an unregistered trigger leaves the panel with nothing to\n  // measure against, so it renders permanently invisible. The root boxes the\n  // trigger exactly (the content portals out of it), so it stands in until a\n  // real trigger registers, and stands in again if that one unmounts. Both are\n  // state, so a trigger arriving while the panel is open re-anchors it.\n  const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(\n    () => ({ current: trigger ?? root }),\n    [root, trigger],\n  );\n\n  // The panel is a `role=\"dialog\"` and goes inert the moment it closes, so\n  // focus cannot be left sitting inside it: a dismissal hands it back to the\n  // trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes\n  // the focus onward itself when it lands on something focusable — this only\n  // catches the case where it would otherwise be stranded. When no trigger has\n  // registered, the root anchor stands in only if it can actually hold focus;\n  // there is nowhere better than where the keyboard already is, so leave it.\n  const close = useCallback(() => {\n    setOpen(false);\n    const focused = document.activeElement;\n    const inPanel =\n      focused instanceof HTMLElement && contentRef.current?.contains(focused);\n    if (!inPanel) return;\n    const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);\n    restore?.focus();\n  }, [root, setOpen, trigger]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (\n        root &&\n        !root.contains(target) &&\n        !contentRef.current?.contains(target)\n      )\n        close();\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, root, close]);\n\n  const ctx = useMemo<MorphContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle,\n      triggerId: `${baseId}-trigger`,\n      contentId: `${baseId}-content`,\n      triggerRef: anchorRef,\n      registerTrigger: setTrigger,\n      contentRef,\n    }),\n    [open, setOpen, toggle, baseId, anchorRef],\n  );\n\n  return (\n    <MorphContext.Provider value={ctx}>\n      <div ref={setRoot} className={cn(\"relative inline-flex\", className)}>\n        {children}\n      </div>\n    </MorphContext.Provider>\n  );\n}\n\nexport interface MorphPopoverTriggerProps {\n  children: ReactElement;\n}\n\nfunction mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\")\n        (ref as React.MutableRefObject<T | null>).current = node;\n    }\n  };\n}\n\n/** Wraps a single element, toggling the popover on click. */\nexport function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {\n  const ctx = useMorphContext(\"MorphPopoverTrigger\");\n  if (!isValidElement(children)) return children;\n\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childOnClick = child.props.onClick as\n    | ((e: unknown) => void)\n    | undefined;\n  const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;\n\n  return cloneElement(child, {\n    id: ctx.triggerId,\n    ref: mergeRefs(childRef, ctx.registerTrigger),\n    onClick: (e: unknown) => {\n      childOnClick?.(e);\n      ctx.toggle();\n    },\n    \"aria-haspopup\": \"dialog\",\n    \"aria-expanded\": ctx.open,\n    \"aria-controls\": ctx.open ? ctx.contentId : undefined,\n  });\n}\n\nconst originFor = (side: Side, align: Align) =>\n  `${side === \"bottom\" ? \"top\" : \"bottom\"} ${align === \"end\" ? \"right\" : \"left\"}`;\n\n// A clip that hides everything but the corner nearest the trigger, so the\n// panel appears to grow out of it. inset(top right bottom left).\nfunction clipHidden(side: Side, align: Align, radius: number) {\n  const top = side === \"bottom\" ? \"0%\" : \"92%\";\n  const bottom = side === \"bottom\" ? \"92%\" : \"0%\";\n  const right = align === \"end\" ? \"0%\" : \"92%\";\n  const left = align === \"end\" ? \"92%\" : \"0%\";\n  return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;\n}\nconst clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;\n\n// Preserve the original spring character on the wrapper, but tween the complex\n// clip-path so it cannot snap when the spring resolves its final distance.\nconst MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;\n\nexport interface MorphPopoverContentProps {\n  children: ReactNode;\n  side?: Side;\n  align?: Align;\n  /** Gap between trigger and panel, in px. Default 8. */\n  sideOffset?: number;\n  /** Panel corner radius, in px. Default 16. */\n  radius?: number;\n  className?: string;\n}\n\nexport function MorphPopoverContent({\n  children,\n  side = \"bottom\",\n  align = \"end\",\n  sideOffset = 8,\n  radius = 16,\n  className,\n}: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const reduce = useReducedMotion() ?? false;\n  const [portalReady, setPortalReady] = useState(false);\n  const layout = usePopoverPortalPosition(\n    ctx.triggerRef,\n    ctx.contentRef,\n    portalReady && ctx.open,\n  );\n\n  useEffect(() => setPortalReady(true), []);\n  const left = layout\n    ? align === \"end\"\n      ? layout.trigger.left + layout.trigger.width - layout.content.width\n      : layout.trigger.left\n    : 0;\n  const top = layout\n    ? side === \"bottom\"\n      ? layout.trigger.top + layout.trigger.height + sideOffset\n      : layout.trigger.top - layout.content.height - sideOffset\n    : 0;\n\n  // Both directions travel between the exact same hidden/show states. Exit\n  // targets \"hidden\" directly instead of introducing separate choreography.\n  const wrap = reduce\n    ? undefined\n    : {\n        hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },\n        show: { opacity: 1, scale: 1, transition: SPRING_PANEL },\n      };\n  const clip = reduce\n    ? undefined\n    : {\n        hidden: {\n          clipPath: clipHidden(side, align, radius),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n        show: {\n          clipPath: clipShown(radius),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n      };\n\n  // Keep the server and first client render identical, then mount the portal.\n  if (!portalReady) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {ctx.open ? (\n        <motion.div\n          data-morph-popover-portal=\"\"\n          // Wrapper carries the shadow as a drop-shadow filter, which hugs the\n          // clipped shape below (box-shadow would just get clipped away).\n          variants={wrap}\n          initial={reduce ? { opacity: 0 } : \"hidden\"}\n          animate={reduce ? { opacity: 1 } : \"show\"}\n          exit={reduce ? { opacity: 0 } : \"hidden\"}\n          transition={reduce ? { duration: 0.12 } : undefined}\n          style={{\n            left,\n            top,\n            visibility: layout ? \"visible\" : \"hidden\",\n            transformOrigin: originFor(side, align),\n          }}\n          className=\"fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]\"\n        >\n          <motion.div\n            ref={ctx.contentRef}\n            id={ctx.contentId}\n            role=\"dialog\"\n            aria-labelledby={ctx.triggerId}\n            variants={clip}\n            style={{ borderRadius: radius }}\n            className={cn(\n              \"overflow-hidden border border-border bg-background\",\n              className,\n            )}\n          >\n            {children}\n          </motion.div>\n        </motion.div>\n      ) : null}\n    </AnimatePresence>,\n    document.body,\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":"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/popover-position.ts","type":"registry:component","target":"@components/motion/popover-position.ts","content":"\"use client\";\n\nimport {\n  type MutableRefObject,\n  useCallback,\n  useLayoutEffect,\n  useState,\n} from \"react\";\n\nexport type PortalLayout = {\n  trigger: {\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  };\n  content: {\n    width: number;\n    height: number;\n  };\n};\n\nfunction sameLayout(a: PortalLayout | null, b: PortalLayout) {\n  return (\n    a?.trigger.left === b.trigger.left &&\n    a.trigger.top === b.trigger.top &&\n    a.trigger.width === b.trigger.width &&\n    a.trigger.height === b.trigger.height &&\n    a.content.width === b.content.width &&\n    a.content.height === b.content.height\n  );\n}\n\n/** Measures a trigger and portalled panel in viewport coordinates. */\nexport function usePopoverPortalPosition<\n  TriggerElement extends HTMLElement,\n  ContentElement extends HTMLElement,\n>(\n  triggerRef: MutableRefObject<TriggerElement | null>,\n  contentRef: MutableRefObject<ContentElement | null>,\n  active: boolean,\n) {\n  const [layout, setLayout] = useState<PortalLayout | null>(null);\n\n  const update = useCallback(() => {\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    if (!trigger || !content) return;\n\n    const rect = trigger.getBoundingClientRect();\n    const next: PortalLayout = {\n      trigger: {\n        left: rect.left,\n        top: rect.top,\n        width: rect.width,\n        height: rect.height,\n      },\n      content: {\n        width: content.offsetWidth,\n        height: content.offsetHeight,\n      },\n    };\n    setLayout((current) => (sameLayout(current, next) ? current : next));\n  }, [contentRef, triggerRef]);\n\n  useLayoutEffect(() => {\n    update();\n    if (!active) return;\n\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    const observer = new ResizeObserver(update);\n    if (trigger) observer.observe(trigger);\n    if (content) observer.observe(content);\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [active, contentRef, triggerRef, update]);\n\n  return layout;\n}\n"}]}