{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-select",
  "title": "Select",
  "description": "Choose from an expanding pill with a gliding highlight, keyboard navigation, and a morphing label.",
  "files": [
    {
      "path": "registry/components/select.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Field, useFieldIds } from \"./field\";\nimport {\n  PillSelect,\n  type PillOption,\n  type PillSelectProps,\n} from \"./pill-select\";\n\n/* ─────────────────────────────────────────────────────────\n * SELECT: a choice from a list, never the browser's dropdown\n *\n *   open     the pill grows into its list (size and corners ease)\n *            from the edge it lines up with; upward when there's\n *            no room below; long lists scroll\n *   pick     the list folds back into the pill and the label\n *            morphs to the choice, which carries a drawn check\n *   keys     arrows glide a highlight, typing finds an option,\n *            Enter picks, Escape folds it back\n *\n * The list lives in a portal, so no toolbar, card or scroller\n * clips it. Labelled and hinted like every other field.\n * ───────────────────────────────────────────────────────── */\n\nexport type SelectOption = PillOption;\n\nexport interface SelectProps extends Omit<\n  PillSelectProps,\n  \"label\" | \"value\" | \"onValueChange\"\n> {\n  /** Shown above; also the accessible name. */\n  label: string;\n  /** Hide the label visually (it stays the accessible name). */\n  hideLabel?: boolean;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string) => void;\n  id?: string;\n}\n\nexport function Select({\n  label,\n  hideLabel,\n  hint,\n  error,\n  value,\n  defaultValue = null,\n  onValueChange,\n  id,\n  className = \"\",\n  block = true,\n  size = \"md\",\n  ...props\n}: SelectProps) {\n  const ids = useFieldIds(id);\n  const [own, setOwn] = React.useState<string | null>(defaultValue);\n  const current = value !== undefined ? value : own;\n  return (\n    <Field\n      id={ids.id}\n      label={hideLabel ? undefined : label}\n      hint={hint}\n      error={error}\n      className={className}\n    >\n      <PillSelect\n        {...props}\n        id={ids.id}\n        aria-describedby={error ? ids.error : hint ? ids.hint : undefined}\n        aria-invalid={error ? true : undefined}\n        block={block}\n        size={size}\n        label={label}\n        value={current}\n        onValueChange={(v) => {\n          if (value === undefined) setOwn(v);\n          onValueChange?.(v);\n        }}\n      />\n    </Field>\n  );\n}\n\nexport default Select;\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/field.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FIELD: the column every text control sits in\n *\n *   label    above, with an optional aside on the right (a count)\n *   hint     under the control while all is well\n *   error    takes the hint's place: the hint folds away, the\n *            error rises in out of a blur, the control shakes once\n *\n * Shared by input, textarea, password, search and select so the\n * whole family has one surface, one focus ring and one error.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FieldProps {\n  id: string;\n  label?: React.ReactNode;\n  /** Right side of the label row, e.g. a character count. */\n  aside?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  className?: string;\n  children: React.ReactNode;\n}\n\nexport type FieldSize = \"sm\" | \"md\";\n\n/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */\nexport function fieldSurface({\n  invalid,\n  disabled,\n  round = true,\n}: {\n  invalid?: boolean;\n  disabled?: boolean;\n  round?: boolean;\n}) {\n  const ring = invalid\n    ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]\"\n    : \"shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]\";\n  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? \"rounded-full\" : \"rounded-[14px]\"} ${ring} ${\n    disabled ? \"pointer-events-none opacity-50\" : \"\"\n  }`;\n}\n\n/** ids for the control's label, hint and error. */\nexport function useFieldIds(id?: string) {\n  const auto = React.useId();\n  const base = id ?? auto;\n  return { id: base, hint: `${base}-hint`, error: `${base}-error` };\n}\n\n/** One short shake whenever `on` turns true (an error arrives). */\nexport function useShake<T extends HTMLElement>(on: boolean) {\n  const ref = React.useRef<T>(null);\n  const reduced = useInteractionReducedMotion();\n  React.useEffect(() => {\n    if (!on || reduced) return;\n    ref.current?.animate(\n      [\n        { transform: \"none\" },\n        { transform: \"translateX(-4px)\" },\n        { transform: \"translateX(4px)\" },\n        { transform: \"translateX(-2px)\" },\n        { transform: \"none\" },\n      ],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  }, [on, reduced]);\n  return ref;\n}\n\n/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */\nexport function setNativeValue(\n  el: HTMLInputElement | HTMLTextAreaElement,\n  value: string,\n) {\n  const proto =\n    el instanceof HTMLTextAreaElement\n      ? HTMLTextAreaElement.prototype\n      : HTMLInputElement.prototype;\n  Object.getOwnPropertyDescriptor(proto, \"value\")?.set?.call(el, value);\n  el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n}\n\n/** A line under the control that folds open and closed; keeps its last words while closing. */\nfunction Line({\n  show,\n  children,\n  id,\n  tone,\n  reduced,\n}: {\n  show: boolean;\n  children: React.ReactNode;\n  id: string;\n  tone: string;\n  reduced: boolean;\n}) {\n  const [kept, setKept] = React.useState(children);\n  if (show && children !== kept) setKept(children);\n  return (\n    <div\n      className=\"grid\"\n      style={{\n        gridTemplateRows: show ? \"1fr\" : \"0fr\",\n        transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE_OUT}`,\n      }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <p\n          id={id}\n          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}\n          style={{\n            opacity: show ? 1 : 0,\n            filter: show ? \"blur(0px)\" : \"blur(4px)\",\n            transform: show ? \"none\" : \"translateY(-4px)\",\n            transition: reduced\n              ? \"none\"\n              : show\n                ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}`\n                : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,\n          }}\n        >\n          {show ? children : kept}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport function Field({\n  id,\n  label,\n  aside,\n  hint,\n  error,\n  required,\n  className = \"\",\n  children,\n}: FieldProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = { hint: `${id}-hint`, error: `${id}-error` };\n  return (\n    <div className={`flex w-full flex-col ${className}`}>\n      {(label || aside) && (\n        <div className=\"flex items-baseline justify-between gap-3 px-3 pb-1.5\">\n          {label && (\n            <label\n              htmlFor={id}\n              className=\"text-[13px] font-medium text-foreground\"\n            >\n              {label}\n              {required && (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"ml-0.5 text-muted-foreground\"\n                >\n                  *\n                </span>\n              )}\n            </label>\n          )}\n          {aside && (\n            <span className=\"text-[12px] tabular-nums text-muted-foreground\">\n              {aside}\n            </span>\n          )}\n        </div>\n      )}\n      {children}\n      <Line\n        show={!error && !!hint}\n        id={ids.hint}\n        tone=\"text-muted-foreground\"\n        reduced={reduced}\n      >\n        {hint}\n      </Line>\n      <Line\n        show={!!error}\n        id={ids.error}\n        tone=\"text-red-600 dark:text-red-400\"\n        reduced={reduced}\n      >\n        {error}\n      </Line>\n    </div>\n  );\n}\n\nexport default Field;\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/pill-select.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * PILL SELECT: a select that's the same surface as its pill\n *\n *   open      the pill grows into its list (size and corners\n *             ease) from the edge it's lined up with, while its\n *             label fades into the options; it opens upward when\n *             there's no room below, and long lists scroll\n *   pick      the list folds back into the pill and the label\n *             morphs to the new choice; the current option\n *             carries a drawn check\n *   keys      arrows move a highlight that glides between rows,\n *             Home and End jump, typing a few letters finds an\n *             option, Enter picks, Escape folds it back\n *\n * The list lives in a portal on the page's body, placed over\n * the pill, so a clipping toolbar, fold or scroller never cuts\n * it off; it carries the theme's colours with it. Scrolling\n * the page closes it.\n *\n * Used wherever a component needs a choice from a list; never\n * the browser's own dropdown, which can't follow the theme.\n * ───────────────────────────────────────────────────────── */\n\nexport interface PillOption {\n  value: string;\n  label: string;\n  /** A second line in the list, e.g. a sample value. */\n  description?: string;\n}\n\nexport interface PillSelectProps {\n  id?: string;\n  \"aria-describedby\"?: string;\n  \"aria-invalid\"?: boolean;\n  options: PillOption[];\n  value: string | null | undefined;\n  onValueChange: (value: string) => void;\n  /** Accessible name, e.g. \"Column for Email\". */\n  label: string;\n  /** Shown while nothing is chosen. */\n  placeholder?: string;\n  /** What the pill shows for a choice; the option's label by default. */\n  display?: (option: PillOption | undefined) => string;\n  /** The pill's contents, when it needs more than text (an icon that turns). */\n  renderValue?: (\n    option: PillOption | undefined,\n    open: boolean,\n  ) => React.ReactNode;\n  /** Monospace labels, for code-like values (event names, column headers). */\n  mono?: boolean;\n  /** Which edge of the pill the list lines up with. */\n  align?: \"start\" | \"end\";\n  /** The list's width; never narrower than the pill. */\n  width?: number;\n  /** Fill the width it's given. */\n  block?: boolean;\n  /** Dims the pill's text, e.g. for \"Don't import\". */\n  muted?: boolean;\n  disabled?: boolean;\n  /** \"sm\" is the 28px pill for toolbars; \"md\" is 36px, the height of the other form fields. */\n  size?: \"sm\" | \"md\";\n  className?: string;\n}\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DURATION = 420;\nconst MAX_LIST = 280;\nconst PILL_SIZE = {\n  sm: \"h-7 px-2.5 text-[12px]\",\n  md: \"h-9 pl-4 pr-3.5 text-[13.5px]\",\n};\n// The theme travels with the list, so it matches the pill wherever the pill's colours were set.\nconst TOKENS = [\n  \"--background\",\n  \"--foreground\",\n  \"--popover\",\n  \"--popover-foreground\",\n  \"--border\",\n  \"--accent\",\n  \"--accent-foreground\",\n  \"--primary\",\n  \"--primary-foreground\",\n  \"--muted\",\n  \"--muted-foreground\",\n  \"--ring\",\n];\nconst FOCUS =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\nfunction Chevron({ up, reduced }: { up: boolean; reduced: boolean }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      className=\"size-3 shrink-0 text-muted-foreground\"\n      style={{\n        transform: up ? \"rotate(180deg)\" : \"none\",\n        transition: reduced ? \"none\" : `transform 320ms ${EASE}`,\n      }}\n    >\n      <path\n        d=\"M4.5 6.25 8 9.75l3.5-3.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport function PillSelect({\n  id: controlId,\n  \"aria-describedby\": describedBy,\n  \"aria-invalid\": invalid,\n  options,\n  value,\n  onValueChange,\n  label,\n  placeholder = \"Choose\",\n  display,\n  renderValue,\n  mono = false,\n  align = \"start\",\n  width = 240,\n  block = false,\n  muted = false,\n  disabled = false,\n  size = \"sm\",\n  className = \"\",\n}: PillSelectProps) {\n  const id = React.useId();\n  const reduced = useReducedMotion();\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [active, setActive] = React.useState(0);\n  const [pill, setPill] = React.useState({ width: 0, height: 28 });\n  const [panelHeight, setPanelHeight] = React.useState(0);\n  const [fit, setFit] = React.useState({\n    up: false,\n    width,\n    shift: 0,\n    x: 0,\n    y: 0,\n    theme: {} as Record<string, string>,\n  });\n  const [highlight, setHighlight] = React.useState({\n    top: 0,\n    height: 0,\n    ready: false,\n  });\n  const [quiet, setQuiet] = React.useState(false);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const timer = React.useRef(0);\n  const typed = React.useRef({ text: \"\", at: 0 });\n  const selected = options.find((o) => o.value === value);\n  // Disabling closes an existing popup before it can accept another choice.\n  if (disabled && phase !== \"closed\") setPhase(\"closed\");\n  const expanded = !disabled && phase === \"open\";\n  const mounted = !disabled && phase !== \"closed\";\n  const [prevPhase, setPrevPhase] = React.useState(phase);\n  if (phase !== prevPhase) {\n    setPrevPhase(phase);\n    if (phase === \"closed\") {\n      setHighlight({ top: 0, height: 0, ready: false });\n    }\n  }\n  const text = display ? display(selected) : (selected?.label ?? placeholder);\n\n  const measureFit = () => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const rect = wrap.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const w = Math.min(Math.max(width, rect.width), vw - 24);\n    // Lined up with the pill's edge, nudged back on screen.\n    const shift =\n      align === \"start\"\n        ? Math.min(0, vw - 12 - (rect.left + w))\n        : Math.max(0, 12 - (rect.right - w));\n    const estimate =\n      Math.min(\n        MAX_LIST,\n        options.length * (options.some((o) => o.description) ? 46 : 32),\n      ) + 12;\n    const vh = window.innerHeight;\n    const below = vh - 8 - rect.top;\n    const above = rect.bottom - 8;\n    const up = below < estimate + 8 && above > below;\n    // Fixed coordinates of the pill's corner the list grows from.\n    const x = align === \"start\" ? rect.left : vw - rect.right;\n    const y = up ? vh - rect.bottom : rect.top;\n    const cs = getComputedStyle(wrap);\n    const theme: Record<string, string> = {\n      colorScheme: cs.colorScheme,\n      fontFamily: cs.fontFamily,\n    };\n    for (const t of TOKENS) {\n      const v = cs.getPropertyValue(t);\n      if (v) theme[t] = v;\n    }\n    setFit({ up, width: w, shift, x, y, theme });\n  };\n\n  const open = () => {\n    if (disabled || phase === \"open\" || phase === \"opening\") return;\n    window.clearTimeout(timer.current);\n    measureFit();\n    setActive(\n      Math.max(\n        0,\n        options.findIndex((o) => o.value === value),\n      ),\n    );\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const close = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) triggerRef.current?.focus({ preventScroll: true });\n      if (reduced) return setPhase(\"closed\");\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), DURATION);\n    },\n    [reduced],\n  );\n\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(\n      () => (inner = requestAnimationFrame(() => setPhase(\"open\"))),\n    );\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n  React.useEffect(() => {\n    if (disabled) window.clearTimeout(timer.current);\n  }, [disabled]);\n\n  React.useLayoutEffect(() => {\n    const trigger = triggerRef.current;\n    if (!trigger) return;\n    const measure = () =>\n      setPill((p) =>\n        p.width === trigger.offsetWidth && p.height === trigger.offsetHeight\n          ? p\n          : { width: trigger.offsetWidth, height: trigger.offsetHeight },\n      );\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(trigger);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (panel) setPanelHeight(panel.offsetHeight);\n  }, [mounted, options]);\n\n  // The highlight glides to the active row, and the list scrolls to keep it in view.\n  React.useLayoutEffect(() => {\n    const row = rowRefs.current[active];\n    const list = listRef.current;\n    if (!row || !list) return;\n    setHighlight((h) =>\n      h.top === row.offsetTop &&\n      h.height === row.offsetHeight &&\n      h.ready === (h.ready || expanded)\n        ? h\n        : {\n            top: row.offsetTop,\n            height: row.offsetHeight,\n            ready: h.ready || expanded,\n          },\n    );\n    const scroller = list.parentElement;\n    if (scroller && scroller.scrollHeight > scroller.clientHeight) {\n      if (row.offsetTop < scroller.scrollTop)\n        scroller.scrollTop = row.offsetTop - 4;\n      else if (\n        row.offsetTop + row.offsetHeight >\n        scroller.scrollTop + scroller.clientHeight\n      )\n        scroller.scrollTop =\n          row.offsetTop + row.offsetHeight - scroller.clientHeight + 4;\n    }\n  }, [active, expanded, mounted]);\n\n  React.useEffect(() => {\n    if (expanded) listRef.current?.focus({ preventScroll: true });\n  }, [expanded]);\n\n  React.useEffect(() => {\n    if (!expanded) return;\n    const inside = (n: Node) =>\n      Boolean(wrapRef.current?.contains(n) || surfaceRef.current?.contains(n));\n    const onDown = (e: PointerEvent) => {\n      if (!inside(e.target as Node)) close(false);\n    };\n    // The list sits where the pill was; if the page moves under it, it goes at once rather than fold in the wrong place.\n    const gone = () => {\n      window.clearTimeout(timer.current);\n      setPhase(\"closed\");\n    };\n    const onScroll = (e: Event) => {\n      if (!surfaceRef.current?.contains(e.target as Node)) gone();\n    };\n    const onResize = gone;\n    document.addEventListener(\"pointerdown\", onDown);\n    window.addEventListener(\"scroll\", onScroll, true);\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"scroll\", onScroll, true);\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [expanded, close]);\n\n  const choose = (o: PillOption) => {\n    if (disabled) return;\n    close();\n    if (o.value !== value) onValueChange(o.value);\n  };\n\n  const onListKey = (e: React.KeyboardEvent) => {\n    const last = options.length - 1;\n    const move = (to: number) => {\n      e.preventDefault();\n      setActive(Math.max(0, Math.min(last, to)));\n    };\n    if (e.key === \"ArrowDown\") move(active === last ? 0 : active + 1);\n    else if (e.key === \"ArrowUp\") move(active === 0 ? last : active - 1);\n    else if (e.key === \"Home\") move(0);\n    else if (e.key === \"End\") move(last);\n    else if (e.key === \"PageDown\") move(active + 6);\n    else if (e.key === \"PageUp\") move(active - 6);\n    else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      if (options[active]) choose(options[active]);\n    } else if (e.key === \"Tab\") close();\n    else if (e.key.length === 1 && /\\S/.test(e.key)) {\n      // A few letters typed quickly find the option that starts with them.\n      const now = performance.now();\n      typed.current = {\n        text:\n          (now - typed.current.at < 700 ? typed.current.text : \"\") +\n          e.key.toLowerCase(),\n        at: now,\n      };\n      const hit = options.findIndex((o) =>\n        o.label.toLowerCase().startsWith(typed.current.text),\n      );\n      if (hit !== -1) setActive(hit);\n    }\n  };\n\n  const w = expanded ? fit.width : pill.width;\n  const h = expanded ? panelHeight : pill.height;\n  const listId = `${id}-list`;\n  const pillText = renderValue ? null : (\n    <>\n      <span\n        className={`min-w-0 flex-1 truncate text-left ${mono ? \"font-mono text-[11.5px]\" : \"\"} ${muted || !selected ? \"text-muted-foreground\" : \"text-foreground\"}`}\n      >\n        {block ? text : <TextMorph>{text}</TextMorph>}\n      </span>\n      <Chevron up={expanded} reduced={reduced} />\n    </>\n  );\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative ${block ? \"flex w-full min-w-0\" : \"inline-flex\"} ${className}`}\n      onKeyDown={(e) => {\n        setQuiet(false);\n        if (e.key === \"Escape\" && expanded) {\n          e.preventDefault();\n          e.stopPropagation();\n          close();\n        }\n      }}\n      onBlur={(e) => {\n        const to = e.relatedTarget as Node | null;\n        if (\n          expanded &&\n          document.hasFocus() &&\n          !(\n            to &&\n            (wrapRef.current?.contains(to) || surfaceRef.current?.contains(to))\n          )\n        )\n          close(false);\n      }}\n    >\n      <button\n        ref={triggerRef}\n        id={controlId}\n        aria-describedby={describedBy}\n        aria-invalid={invalid}\n        data-invalid={invalid || undefined}\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={expanded}\n        aria-controls={mounted ? listId : undefined}\n        aria-label={`${label}: ${selected?.label ?? placeholder}`}\n        disabled={disabled}\n        onClick={() => (expanded ? close() : open())}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            open();\n          }\n        }}\n        onPointerDown={() => setQuiet(true)}\n        className={`inline-flex ${PILL_SIZE[size]} min-w-0 cursor-pointer items-center gap-1.5 rounded-full font-medium whitespace-nowrap shadow-[inset_0_0_0_1px_var(--border)] transition-colors duration-150 hover:bg-accent disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent ${block ? \"w-full\" : \"\"} ${quiet ? \"outline-none\" : FOCUS}`}\n      >\n        {renderValue ? renderValue(selected, expanded) : pillText}\n      </button>\n\n      {mounted &&\n        createPortal(\n          <div\n            ref={surfaceRef}\n            onKeyDown={(e) => {\n              if (e.key === \"Escape\") {\n                e.preventDefault();\n                e.stopPropagation();\n                close();\n              }\n            }}\n            onPointerDown={(e) => {\n              if (!(e.target as HTMLElement).closest(\"[data-scroll]\"))\n                e.preventDefault();\n              setQuiet(true);\n            }}\n            className=\"fixed z-[60] overflow-hidden bg-popover text-popover-foreground\"\n            style={{\n              ...fit.theme,\n              pointerEvents: expanded ? \"auto\" : \"none\",\n              [fit.up ? \"bottom\" : \"top\"]: fit.y,\n              [align === \"start\" ? \"left\" : \"right\"]:\n                fit.x +\n                (expanded ? (align === \"start\" ? fit.shift : -fit.shift) : 0),\n              width: w,\n              height: h,\n              borderRadius: expanded ? 16 : pill.height / 2,\n              // The pill's own hairline, the whole way: it is the same surface.\n              boxShadow: \"inset 0 0 0 1px var(--border)\",\n              transition: reduced\n                ? \"none\"\n                : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, left ${DURATION}ms ${EASE}, right ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,\n            }}\n          >\n            {/* The pill's label, fading out as the surface grows and back in as it folds. */}\n            <span\n              aria-hidden=\"true\"\n              className={`pointer-events-none absolute inline-flex ${PILL_SIZE[size]} items-center gap-1.5 font-medium whitespace-nowrap ${fit.up ? \"bottom-0\" : \"top-0\"} ${align === \"start\" ? \"left-0\" : \"right-0\"}`}\n              style={{\n                width: pill.width,\n                opacity: expanded ? 0 : 1,\n                transition: reduced\n                  ? \"none\"\n                  : `opacity ${expanded ? 120 : 260}ms ${EASE} ${expanded ? 0 : 120}ms`,\n              }}\n            >\n              {renderValue ? renderValue(selected, expanded) : pillText}\n            </span>\n            <div\n              ref={panelRef}\n              inert={!expanded}\n              aria-hidden={!expanded}\n              className={`absolute p-1 ${fit.up ? \"bottom-0\" : \"top-0\"} ${align === \"start\" ? \"left-0\" : \"right-0\"}`}\n              style={{\n                width: fit.width,\n                opacity: expanded ? 1 : 0,\n                transition: reduced\n                  ? \"none\"\n                  : `opacity ${expanded ? 260 : 120}ms ${EASE} ${expanded ? 90 : 0}ms`,\n              }}\n            >\n              <div\n                data-scroll=\"\"\n                className=\"overflow-y-auto overscroll-contain\"\n                style={{ maxHeight: MAX_LIST }}\n              >\n                <div\n                  ref={listRef}\n                  id={listId}\n                  role=\"listbox\"\n                  tabIndex={-1}\n                  aria-label={label}\n                  aria-activedescendant={`${id}-o${active}`}\n                  onKeyDown={onListKey}\n                  className=\"relative outline-none\"\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none absolute inset-x-0 top-0 rounded-[12px] bg-accent\"\n                    style={{\n                      height: highlight.height,\n                      transform: `translateY(${highlight.top}px)`,\n                      opacity: highlight.ready ? 1 : 0,\n                      transition:\n                        highlight.ready && !reduced\n                          ? `transform 260ms ${EASE}, height 260ms ${EASE}, opacity 200ms`\n                          : \"none\",\n                    }}\n                  />\n                  {options.map((o, i) => {\n                    const on = o.value === value;\n                    return (\n                      <div\n                        key={o.value}\n                        ref={(el) => {\n                          rowRefs.current[i] = el;\n                        }}\n                        id={`${id}-o${i}`}\n                        role=\"option\"\n                        aria-selected={on}\n                        onPointerMove={() => setActive(i)}\n                        onClick={() => choose(o)}\n                        className=\"relative flex cursor-pointer items-start gap-2 rounded-[12px] px-2.5 py-1.5\"\n                      >\n                        <span className=\"min-w-0 flex-1\">\n                          <span\n                            className={`block truncate ${mono ? \"font-mono text-[12px]\" : \"text-[12.5px] font-medium\"}`}\n                          >\n                            {o.label}\n                          </span>\n                          {o.description && (\n                            <span className=\"mt-0.5 block truncate text-[11.5px] text-muted-foreground\">\n                              {o.description}\n                            </span>\n                          )}\n                        </span>\n                        <svg\n                          viewBox=\"0 0 16 16\"\n                          fill=\"none\"\n                          aria-hidden=\"true\"\n                          className=\"mt-0.5 size-3.5 shrink-0\"\n                        >\n                          <path\n                            d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                            stroke=\"currentColor\"\n                            strokeWidth=\"1.8\"\n                            strokeLinecap=\"round\"\n                            strokeLinejoin=\"round\"\n                            pathLength={1}\n                            strokeDasharray={1}\n                            style={{\n                              strokeDashoffset: on && expanded ? 0 : 1,\n                              transition: reduced\n                                ? \"none\"\n                                : `stroke-dashoffset 380ms ${EASE} 160ms`,\n                            }}\n                          />\n                        </svg>\n                      </div>\n                    );\n                  })}\n                </div>\n              </div>\n            </div>\n          </div>,\n          document.body,\n        )}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/text-morph.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * TEXT MORPH: text that changes the way it should\n *\n *   shared   letters both texts share stay alive and slide to\n *            their new place (\"Save\" → \"Saving\" keeps \"Sav\")\n *   new      letters that weren't there rise in out of a blur,\n *            one after another\n *   gone     letters that leave lift away and blur out where\n *            they stood\n *   width    the box eases to its new width, so a button or\n *            pill holding it resizes with it\n *\n * Texts with little in common crossfade as a whole instead of\n * flinging single letters across. The real text is always\n * there for screen readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface TextMorphProps extends Omit<\n  React.HTMLAttributes<HTMLSpanElement>,\n  \"children\"\n> {\n  children: string;\n  /** Morph time in ms. */\n  duration?: number;\n  /** Where new letters come from: below (\"up\") or above (\"down\"). */\n  direction?: \"up\" | \"down\";\n  /** Ease the box's width too; turn off for text that wraps. */\n  animateWidth?: boolean;\n}\n\ntype Glyph = { key: number; char: string };\ntype Leaving = Glyph & { x: number; y: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\n/** Keep the letters two texts share (longest common subsequence); everything else is new. */\nfunction morph(\n  previous: Glyph[],\n  text: string,\n  nextKey: () => number,\n): Glyph[] {\n  const a = previous.map((g) => g.char);\n  const b = Array.from(text);\n  const fresh = () => b.map((char) => ({ key: nextKey(), char }));\n  if (!a.length || !b.length) return fresh();\n\n  const dp = Array.from(\n    { length: a.length + 1 },\n    () => new Uint16Array(b.length + 1),\n  );\n  for (let i = a.length - 1; i >= 0; i--)\n    for (let j = b.length - 1; j >= 0; j--)\n      dp[i][j] =\n        a[i] === b[j]\n          ? dp[i + 1][j + 1] + 1\n          : Math.max(dp[i + 1][j], dp[i][j + 1]);\n\n  // Too little in common reads as two different words: crossfade them instead.\n  if (dp[0][0] < Math.min(a.length, b.length) * 0.5) return fresh();\n\n  const out: Glyph[] = [];\n  let i = 0;\n  let j = 0;\n  while (j < b.length) {\n    if (i < a.length && a[i] === b[j]) {\n      out.push(previous[i++]);\n      j++;\n    } else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) i++;\n    else out.push({ key: nextKey(), char: b[j++] });\n  }\n  return out;\n}\n\nfunction LeavingGlyph({\n  glyph,\n  lift,\n  duration,\n  onDone,\n}: {\n  glyph: Leaving;\n  lift: number;\n  duration: number;\n  onDone: (key: number) => void;\n}) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const animation = el.animate(\n      [\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n        { opacity: 0, filter: \"blur(4px)\", transform: `translateY(${lift}em)` },\n      ],\n      { duration: duration * 0.6, easing: EASE, fill: \"forwards\" },\n    );\n    animation.onfinish = () => onDone(glyph.key);\n    return () => animation.cancel();\n  }, [glyph.key, lift, duration, onDone]);\n  return (\n    <span\n      ref={ref}\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute whitespace-pre\"\n      style={{ left: glyph.x, top: glyph.y }}\n    >\n      {glyph.char}\n    </span>\n  );\n}\n\nexport function TextMorph({\n  children: text,\n  duration = 460,\n  direction = \"up\",\n  animateWidth = true,\n  className = \"\",\n  ...props\n}: TextMorphProps) {\n  const reduced = useReducedMotion();\n  const counter = React.useRef(text.length);\n  const nextKey = React.useCallback(() => ++counter.current, []);\n  const [glyphs, setGlyphs] = React.useState<Glyph[]>(() =>\n    Array.from(text).map((char, i) => ({ key: i + 1, char })),\n  );\n  const [leaving, setLeaving] = React.useState<Leaving[]>([]);\n  const rootRef = React.useRef<HTMLSpanElement>(null);\n  const els = React.useRef(new Map<number, HTMLSpanElement>());\n  const before = React.useRef<{\n    at: Map<number, { x: number; y: number }>;\n    width: number;\n  } | null>(null);\n  const rise = direction === \"up\" ? 0.35 : -0.35;\n\n  // A preference change must also finish an in-flight morph whose target text\n  // already matches the glyphs (including the empty label in a folded receipt).\n  React.useLayoutEffect(() => {\n    if (!reduced) return;\n    before.current = null;\n    const root = rootRef.current;\n    root\n      ?.getAnimations({ subtree: true })\n      .forEach((animation) => animation.cancel());\n    if (root) root.style.whiteSpace = \"\";\n    setLeaving((previous) => (previous.length ? [] : previous));\n  }, [reduced]);\n\n  // 1. The text changed: note where every letter stands, then work out which letters stay.\n  React.useLayoutEffect(() => {\n    if (text === glyphs.map((g) => g.char).join(\"\")) return;\n    const root = rootRef.current;\n    if (!root || reduced) {\n      setLeaving([]);\n      setGlyphs(Array.from(text).map((char) => ({ key: nextKey(), char })));\n      return;\n    }\n    const box = root.getBoundingClientRect();\n    const at = new Map<number, { x: number; y: number }>();\n    els.current.forEach((el, key) => {\n      const r = el.getBoundingClientRect();\n      at.set(key, { x: r.left - box.left, y: r.top - box.top });\n    });\n    before.current = { at, width: box.width };\n    const next = morph(glyphs, text, nextKey);\n    const kept = new Set(next.map((g) => g.key));\n    setLeaving((l) => [\n      ...l,\n      ...glyphs\n        .filter((g) => !kept.has(g.key))\n        .map((g) => ({ ...g, ...(at.get(g.key) ?? { x: 0, y: 0 }) })),\n    ]);\n    setGlyphs(next);\n  }, [text, reduced]);\n\n  // 2. The new letters are in place: glide the ones that stayed, raise the new ones, ease the width.\n  React.useLayoutEffect(() => {\n    const prev = before.current;\n    const root = rootRef.current;\n    if (!prev || !root) return;\n    before.current = null;\n    const box = root.getBoundingClientRect();\n    let order = 0;\n    for (const g of glyphs) {\n      const el = els.current.get(g.key);\n      if (!el) continue;\n      const from = prev.at.get(g.key);\n      if (from) {\n        const r = el.getBoundingClientRect();\n        const dx = from.x - (r.left - box.left);\n        const dy = from.y - (r.top - box.top);\n        if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)\n          el.animate(\n            [\n              { transform: `translate(${dx}px, ${dy}px)` },\n              { transform: \"none\" },\n            ],\n            { duration, easing: EASE },\n          );\n      } else {\n        el.animate(\n          [\n            {\n              opacity: 0,\n              filter: \"blur(4px)\",\n              transform: `translateY(${rise}em)`,\n            },\n            { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n          ],\n          {\n            duration,\n            easing: EASE,\n            delay: Math.min(order++ * 16, 160),\n            fill: \"backwards\",\n          },\n        );\n      }\n    }\n    if (animateWidth && Math.abs(prev.width - box.width) > 0.5) {\n      // Hold the words on one line while the box is narrower than them, or \"No credits left\" would wrap mid-morph.\n      root.style.whiteSpace = \"nowrap\";\n      const resize = root.animate(\n        [{ width: `${prev.width}px` }, { width: `${box.width}px` }],\n        { duration, easing: EASE },\n      );\n      resize.onfinish = resize.oncancel = () => {\n        root.style.whiteSpace = \"\";\n      };\n    }\n  }, [glyphs, duration, rise, animateWidth]);\n\n  const done = React.useCallback(\n    (key: number) => setLeaving((l) => l.filter((g) => g.key !== key)),\n    [],\n  );\n\n  // Words stay unbroken; lines may only wrap at spaces.\n  const words: Glyph[][] = [];\n  for (const g of glyphs) {\n    if (\n      g.char === \" \" ||\n      !words.length ||\n      words[words.length - 1][0]?.char === \" \"\n    )\n      words.push([g]);\n    else words[words.length - 1].push(g);\n  }\n\n  return (\n    <span\n      ref={rootRef}\n      className={`relative inline-block ${className}`}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      {words.map((word, w) => (\n        <span\n          key={w}\n          aria-hidden=\"true\"\n          className=\"inline-block whitespace-pre\"\n        >\n          {word.map((g) => (\n            <span\n              key={g.key}\n              ref={(el) => {\n                if (el) els.current.set(g.key, el);\n                else els.current.delete(g.key);\n              }}\n              className=\"inline-block\"\n            >\n              {g.char}\n            </span>\n          ))}\n        </span>\n      ))}\n      {leaving.map((g) => (\n        <LeavingGlyph\n          key={`out-${g.key}`}\n          glyph={g}\n          lift={-rise}\n          duration={duration}\n          onDone={done}\n        />\n      ))}\n    </span>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/ui-interaction-motion.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport const EASE_OUT = \"cubic-bezier(0.16, 1, 0.3, 1)\";\nexport const EASE_SETTLE = \"cubic-bezier(0.22, 1.18, 0.36, 1)\";\nexport const INTERACTION_FOCUS =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\n\nconst query = \"(prefers-reduced-motion: reduce)\";\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", notify);\n  return () => media.removeEventListener(\"change\", notify);\n};\nexport const useInteractionReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) =>\n      setVisible(entry.isIntersecting),\n    );\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility();\n    document.addEventListener(\"visibilitychange\", visibility);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", visibility);\n    };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(\n  on: boolean,\n  reduced: boolean,\n): React.CSSProperties {\n  return {\n    opacity: on ? 1 : 0,\n    transform: on ? \"none\" : \"scale(0.6)\",\n    filter: on ? \"blur(0px)\" : \"blur(3px)\",\n    transition: reduced\n      ? \"none\"\n      : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}`,\n  };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(\n  samples: PointerSample[],\n  x: number,\n  time: number,\n) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100)\n    return 0;\n  const first = samples[0],\n    last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 520,\n) {\n  const omega = 20,\n    damping = 0.82,\n    damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient =\n    (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = ((i / 32) * duration) / 1000;\n    const value =\n      to +\n      Math.exp(-damping * omega * t) *\n        (displacement * Math.cos(damped * t) +\n          coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "license": "MIT",
    "cavemio": {
      "component": "select",
      "exportName": "Select",
      "usage": "import { Select } from \"@/components/select\";\n\n<Select label=\"Plan\" options={[{ value: \"personal\", label: \"Personal\" }, { value: \"team\", label: \"Team\" }]} defaultValue=\"personal\" />",
      "accessibility": "Labeled listbox with arrow keys, Home/End, typeahead, Enter/Space selection, Escape dismissal, focus restoration, and live reduced-motion support.",
      "previewNote": "Try choosing a plan, typing to find a language, and scrolling the long list.",
      "categories": [
        "interactions"
      ],
      "documentation": {
        "props": [
          {
            "name": "options",
            "type": "SelectOption[]",
            "description": "Choices with unique value, label, and optional description."
          },
          {
            "name": "label",
            "type": "string",
            "description": "Visible label and accessible name."
          },
          {
            "name": "value",
            "type": "string | null",
            "description": "Controlled selection; use onValueChange to update it."
          },
          {
            "name": "defaultValue",
            "type": "string | null",
            "description": "Initial uncontrolled selection."
          },
          {
            "name": "onValueChange",
            "type": "(value: string) => void",
            "description": "Called when a different option is selected."
          },
          {
            "name": "hint / error",
            "type": "ReactNode",
            "description": "Supporting text or validation feedback linked to the control."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "description": "Prevents opening and selection."
          },
          {
            "name": "hideLabel",
            "type": "boolean",
            "description": "Visually hides the label while retaining its accessible name."
          },
          {
            "name": "block",
            "type": "boolean",
            "description": "Fill available width; defaults to true. Compact pills morph their label."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\"",
            "description": "Compact toolbar or regular field height."
          },
          {
            "name": "align / width",
            "type": "\"start\" | \"end\" / number",
            "description": "Popup alignment and preferred width, constrained to viewport."
          },
          {
            "name": "display / renderValue",
            "type": "function",
            "description": "Customize selected text or render an icon/value."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Styles the field wrapper."
          }
        ],
        "useCases": [],
        "features": [],
        "states": []
      }
    }
  },
  "type": "registry:component"
}