{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-command-palette",
  "title": "Command Palette",
  "description": "Search commands from a compact keyboard shortcut trigger with fuzzy matching, nested actions, and a moving result highlight.",
  "files": [
    {
      "path": "registry/components/command-palette.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TextMorph } from \"./text-morph\";\nimport { Kbd } from \"./kbd\";\n\n/* ─────────────────────────────────────────────────────────\n * COMMAND PALETTE: ⌘K, from the pill that was always there\n *\n *   closed    an action label followed by a Kbd shortcut badge\n *   opening   the pill itself lifts and grows into the palette:\n *             position, size and corners ease from pill to panel\n *             while its placeholder becomes the field's\n *   search    results narrow as you type, the letters that match\n *             are marked, the panel's height eases to fit and a\n *             highlight glides between rows\n *   nested    \"Change theme…\" opens its own list in place: it\n *             slides in from the right as a chip joins the field;\n *             Backspace slides back\n *   running   a command's shortcut blurs into a spinner, then a\n *             check that draws itself, and the palette folds back\n *             into the pill, which it lands as exactly (hotkey\n *             and all), so the hand-off never shows\n *   empty     \"No results for “xyz”\", morphing as you type\n *\n * A real dialog: focus moves in and back, arrow keys, Home/End,\n * Enter, Escape (back, then close) and the hotkey to toggle.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CommandItem {\n  id: string;\n  label: string;\n  /** Rows are grouped under this heading, in the order groups first appear. */\n  group?: string;\n  icon?: React.ReactNode;\n  /** Shown on the right, e.g. [\"⌘\", \"N\"]. */\n  shortcut?: string[];\n  /** Other words that should find it, e.g. [\"dark\", \"light\"] for \"Change theme\". */\n  keywords?: string[];\n  description?: string;\n  /** Navigate here when chosen. */\n  href?: string;\n  /** Run it. Return a promise to show progress on the row before the palette closes. */\n  onSelect?: (context: { signal: AbortSignal }) => void | Promise<unknown>;\n  /** A nested list, opened in place (e.g. \"Change theme…\"). */\n  items?: CommandItem[];\n}\n\nexport interface CommandFilter {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n}\n\nexport interface CommandPaletteProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onSelect\"\n> {\n  items: CommandItem[];\n  placeholder?: string;\n  /** Accessible name of the search field. */\n  searchLabel?: string;\n  /** The letter that opens it with ⌘ (or Ctrl). Empty string turns the hotkey off. */\n  hotkey?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Show the command menu trigger. Without it, open with the hotkey or the open prop. */\n  showTrigger?: boolean;\n  /** The pill's words. */\n  triggerLabel?: string;\n  /** Leading trigger icon. Defaults to search; pass null to omit it. */\n  triggerIcon?: React.ReactNode;\n  /** Caller-owned active search scopes. The caller also filters items. */\n  filters?: CommandFilter[];\n  filtersLabel?: string;\n  onFilterRemove?: (id: string) => void;\n  /** Optional help copy, links, or actions alongside the keyboard hints. */\n  footer?: React.ReactNode;\n}\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\ntype Page = { title: string; items: CommandItem[] };\ntype Row = { item: CommandItem; marks: number[]; score: number };\ntype Rect = {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n  radius: number;\n};\ntype RunState = { id: string; state: \"pending\" | \"done\" | \"error\" };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst OPEN_DURATION = 440;\nconst CLOSE_DURATION = 320;\nconst WIDTH = 640;\nconst ROW = 40;\nconst LIST_MAX = 440;\nconst FOCUS =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\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/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced\n    ? \"none\"\n    : `opacity 220ms ${EASE}, transform 340ms ${EASE}, filter 220ms ${EASE}`,\n});\n\n/** Where the query's letters land in the text: one run if it's there whole, otherwise in order. */\nfunction match(text: string, query: string): number[] | null {\n  const t = text.toLowerCase();\n  const q = query.toLowerCase().replace(/\\s+/g, \" \").trim();\n  if (!q) return [];\n  const at = t.indexOf(q);\n  if (at >= 0) return Array.from({ length: q.length }, (_, i) => at + i);\n  const marks: number[] = [];\n  let j = 0;\n  for (let i = 0; i < t.length && j < q.length; i++) {\n    if (q[j] === \" \") j++;\n    if (t[i] === q[j]) {\n      marks.push(i);\n      j++;\n    }\n  }\n  return j >= q.length ? marks : null;\n}\n\nfunction rank(items: CommandItem[], query: string): Row[] {\n  const q = query.trim();\n  if (!q) return items.map((item) => ({ item, marks: [], score: 0 }));\n  const rows: Row[] = [];\n  for (const item of items) {\n    const marks = match(item.label, q);\n    if (marks) {\n      const contiguous =\n        marks.length > 1 &&\n        marks[marks.length - 1] - marks[0] === marks.length - 1;\n      const start = marks[0] ?? 0;\n      const wordStart = start === 0 || item.label[start - 1] === \" \";\n      const score =\n        (contiguous ? 60 : 30) +\n        (start === 0 ? 40 : wordStart ? 20 : 0) -\n        (marks[marks.length - 1] - start) * 0.5;\n      rows.push({ item, marks, score });\n    } else if (\n      item.keywords?.some((k) => match(k, q)) ||\n      (item.description && match(item.description, q))\n    ) {\n      rows.push({ item, marks: [], score: 10 });\n    }\n  }\n  return rows.sort((a, b) => b.score - a.score);\n}\n\nfunction Marked({ text, marks }: { text: string; marks: number[] }) {\n  if (!marks.length) return <>{text}</>;\n  const set = new Set(marks);\n  return (\n    <>\n      {Array.from(text).map((ch, i) =>\n        set.has(i) ? (\n          <span key={i} className=\"font-medium text-foreground\">\n            {ch}\n          </span>\n        ) : (\n          <React.Fragment key={i}>{ch}</React.Fragment>\n        ),\n      )}\n    </>\n  );\n}\n\nfunction SearchIcon({ className = \"\" }: { className?: string }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      className={`size-4 shrink-0 ${className}`}\n    >\n      <circle cx=\"7\" cy=\"7\" r=\"4.75\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path\n        d=\"m10.5 10.5 3 3\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction Check({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4\">\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: drawn ? 0 : 1,\n          transition:\n            drawn && !reduced ? `stroke-dashoffset 360ms ${EASE} 80ms` : \"none\",\n        }}\n      />\n    </svg>\n  );\n}\n\n/** One page of results. Kept as its own piece so the page you leave can slide out while the next slides in. */\nfunction Results({\n  rows,\n  active,\n  run,\n  reduced,\n  listId,\n  optionId,\n  setActive,\n  choose,\n  rowRefs,\n}: {\n  rows: Row[];\n  active: number;\n  run: RunState | null;\n  reduced: boolean;\n  listId?: string;\n  optionId?: (i: number) => string;\n  setActive?: (i: number) => void;\n  choose?: (i: number) => void;\n  rowRefs?: React.MutableRefObject<(HTMLDivElement | null)[]>;\n}) {\n  return (\n    <div\n      id={listId}\n      role={listId ? \"listbox\" : undefined}\n      aria-label={listId ? \"Commands\" : undefined}\n    >\n      {rows.map((row, i) => {\n        const { item } = row;\n        const heading =\n          item.group && item.group !== rows[i - 1]?.item.group\n            ? item.group\n            : null;\n        const running = run?.id === item.id;\n        const state = running ? run.state : null;\n        return (\n          <React.Fragment key={item.id}>\n            {heading && (\n              <div\n                role=\"presentation\"\n                className={`px-3 pb-1 pt-2 text-[12px] font-medium text-muted-foreground ${i > 0 ? \"mt-1 border-t border-border\" : \"\"}`}\n              >\n                {heading}\n              </div>\n            )}\n            <div\n              ref={(el) => {\n                if (rowRefs) rowRefs.current[i] = el;\n              }}\n              id={optionId?.(i)}\n              role=\"option\"\n              aria-selected={i === active}\n              aria-disabled={Boolean(run) || undefined}\n              onPointerMove={() => i !== active && setActive?.(i)}\n              onClick={() => choose?.(i)}\n              className=\"relative flex cursor-pointer items-center gap-3 rounded-xl px-3 text-[13.5px] text-foreground/80\"\n              style={{ height: ROW }}\n            >\n              {item.icon && (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"flex size-5 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-5 [&_svg]:stroke-[1.7] [&_img]:size-5 [&_img]:object-contain\"\n                >\n                  {item.icon}\n                </span>\n              )}\n              <span className=\"min-w-0 truncate\">\n                <Marked text={item.label} marks={row.marks} />\n                {item.items && <span className=\"text-muted-foreground\">…</span>}\n              </span>\n              {item.description && (\n                <span className=\"hidden min-w-0 flex-1 truncate text-[12px] text-muted-foreground sm:block\">\n                  {item.description}\n                </span>\n              )}\n              {/* The right edge: shortcut or chevron, which blurs into a spinner and then a check while it runs. */}\n              <span className=\"relative ml-auto flex h-5 shrink-0 items-center justify-end\">\n                <span\n                  className=\"flex items-center gap-1\"\n                  style={swap(!state, reduced)}\n                >\n                  {item.items ? (\n                    <svg\n                      aria-hidden=\"true\"\n                      viewBox=\"0 0 12 12\"\n                      className=\"size-3 text-muted-foreground\"\n                    >\n                      <path\n                        d=\"m4.5 2.5 3.5 3.5-3.5 3.5\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"1.4\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                      />\n                    </svg>\n                  ) : (\n                    item.shortcut?.map((k) => <Kbd key={k}>{k}</Kbd>)\n                  )}\n                </span>\n                <span\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-y-0 right-0 flex items-center\"\n                  style={swap(state === \"pending\", reduced)}\n                >\n                  <span\n                    className={`size-3.5 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${state === \"pending\" ? \"animate-spin\" : \"\"}`}\n                  />\n                </span>\n                <span\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-y-0 right-0 flex items-center text-[var(--success,var(--color-emerald-600))]\"\n                  style={swap(state === \"done\", reduced)}\n                >\n                  <Check drawn={state === \"done\"} reduced={reduced} />\n                </span>\n              </span>\n            </div>\n          </React.Fragment>\n        );\n      })}\n    </div>\n  );\n}\n\nexport function CommandPalette({\n  items,\n  placeholder = \"Search or jump to…\",\n  hotkey = \"k\",\n  searchLabel = \"Search commands\",\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  showTrigger = true,\n  triggerLabel = \"Search...\",\n  triggerIcon = <SearchIcon />,\n  filters = [],\n  filtersLabel = \"Recent searches\",\n  onFilterRemove,\n  footer,\n  className = \"\",\n  ...props\n}: CommandPaletteProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [phase, setPhase] = React.useState<Phase>(\n    defaultOpen ? \"open\" : \"closed\",\n  );\n  const [query, setQuery] = React.useState(\"\");\n  const [stack, setStack] = React.useState<Page[]>([]);\n  const [active, setActive] = React.useState(0);\n  const [run, setRun] = React.useState<RunState | null>(null);\n  const [from, setFrom] = React.useState<Rect | null>(null);\n  const [listHeight, setListHeight] = React.useState(0);\n  const [highlight, setHighlight] = React.useState({\n    top: 0,\n    ready: false,\n    depth: 0,\n  });\n  const [leaving, setLeaving] = React.useState<\n    { key: number; rows: Row[]; active: number; dir: number; top: number }[]\n  >([]);\n  const [enterDir, setEnterDir] = React.useState(0);\n  const [portal, setPortal] = React.useState<HTMLElement | null>(null);\n\n  const [viewport, setViewport] = React.useState({\n    width: 0,\n    height: 0,\n    top: 0,\n  });\n  const overlayRef = React.useRef<HTMLDivElement>(null);\n  const closeRef = React.useRef<HTMLButtonElement>(null);\n  const footerRef = React.useRef<HTMLDivElement>(null);\n  const [footerHeight, setFooterHeight] = React.useState(48);\n  const wasMounted = React.useRef(false);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const innerRef = React.useRef<HTMLDivElement>(null);\n  const paneRef = React.useRef<HTMLDivElement>(null);\n  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const returnFocus = React.useRef<HTMLElement | null>(null);\n  const timer = React.useRef(0);\n  const commandRun = React.useRef<AbortController | null>(null);\n  const failureMotion = React.useRef<Animation | null>(null);\n  const [error, setError] = React.useState(\"\");\n  const leaveKey = React.useRef(0);\n\n  const isOpen = phase === \"open\";\n  const mounted = phase !== \"closed\";\n  const page = stack[stack.length - 1];\n  const pageItems = page ? page.items : items;\n  const rows = React.useMemo(() => rank(pageItems, query), [pageItems, query]);\n\n  React.useEffect(() => {\n    // Browser-only portal discovery after hydration.\n\n    setPortal(document.body);\n  }, []);\n\n  // Where the pill is, so the palette can grow out of it (and fold back into it).\n  const pillRect = (): Rect | null => {\n    const el = triggerRef.current;\n    if (!el || !showTrigger) return null;\n    const r = el.getBoundingClientRect();\n    // Off screen, or hidden (a pill tucked away on narrow screens has no box): open in place instead.\n    if (!r.width || r.bottom < 0 || r.top > window.innerHeight) return null;\n    return {\n      left: r.left,\n      top: r.top,\n      width: r.width,\n      height: r.height,\n      radius: parseFloat(getComputedStyle(el).borderTopLeftRadius),\n    };\n  };\n\n  const target = (): Rect => {\n    const vw = viewport.width || document.documentElement.clientWidth;\n    const vh = viewport.height || window.innerHeight;\n    const width = Math.min(WIDTH, vw - 24);\n    const compactTop = Math.max(16, Math.min(96, Math.round(vh * 0.16)));\n    // Shorten travel when the full result area fits, without taking space from\n    // the list on short screens. Reserve the maximum height so searching or\n    // entering a nested page does not move the shell's anchor.\n    const fullHeight = 52 + LIST_MAX + 12 + footerHeight;\n    const top = Math.max(\n      compactTop,\n      Math.min(160, Math.round(vh * 0.16), vh - fullHeight - 16),\n    );\n    return {\n      left: (vw - width) / 2,\n      top: viewport.top + top,\n      width,\n      height: 0,\n      radius: 14,\n    };\n  };\n\n  // Reset before mounting the input. A post-mount effect can erase typing\n  // delivered during the opening frames, making Enter choose the wrong row.\n  const resetSession = React.useCallback(() => {\n    commandRun.current?.abort();\n    failureMotion.current?.cancel();\n    window.clearTimeout(timer.current);\n    setError(\"\");\n    setQuery(\"\");\n    setStack([]);\n    setActive(0);\n    setRun(null);\n    setLeaving([]);\n    setHighlight({ top: 0, ready: false, depth: 0 });\n  }, []);\n\n  const setOpen = React.useCallback(\n    (next: boolean) => {\n      if (!next) {\n        commandRun.current?.abort();\n        failureMotion.current?.cancel();\n      }\n      if (openProp === undefined) {\n        window.clearTimeout(timer.current);\n        if (next) {\n          if (phase === \"closed\") {\n            returnFocus.current = document.activeElement as HTMLElement | null;\n            resetSession();\n          }\n          setFrom(pillRect());\n          setPhase(reduced ? \"open\" : \"opening\");\n        } else {\n          setFrom(pillRect());\n          if (reduced) setPhase(\"closed\");\n          else {\n            setPhase(\"closing\");\n            timer.current = window.setTimeout(\n              () => setPhase(\"closed\"),\n              CLOSE_DURATION,\n            );\n          }\n        }\n      }\n      onOpenChange?.(next);\n    },\n\n    [openProp, onOpenChange, reduced, showTrigger, phase, resetSession],\n  );\n\n  // Controlled: follow the prop through the same phases.\n  React.useEffect(() => {\n    if (openProp === undefined) return;\n    window.clearTimeout(timer.current);\n    if (openProp && (phase === \"closed\" || phase === \"closing\")) {\n      if (phase === \"closed\") {\n        returnFocus.current = document.activeElement as HTMLElement | null;\n        // Synchronize the controlled prop before exposing a new search session.\n\n        resetSession();\n      }\n      setFrom(pillRect());\n      setPhase(reduced ? \"open\" : \"opening\");\n    } else if (!openProp && (phase === \"open\" || phase === \"opening\")) {\n      commandRun.current?.abort();\n      failureMotion.current?.cancel();\n      setFrom(pillRect());\n      if (reduced) setPhase(\"closed\");\n      else {\n        setPhase(\"closing\");\n        timer.current = window.setTimeout(\n          () => setPhase(\"closed\"),\n          CLOSE_DURATION,\n        );\n      }\n    }\n  }, [openProp]);\n\n  // Paint at the pill's size first, then let it grow.\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\n  // Accept typing from the first mounted frame, including while the pill grows.\n  React.useLayoutEffect(() => {\n    if (mounted) inputRef.current?.focus({ preventScroll: true });\n  }, [mounted, portal]);\n\n  React.useEffect(\n    () => () => {\n      window.clearTimeout(timer.current);\n      commandRun.current?.abort();\n      failureMotion.current?.cancel();\n    },\n    [],\n  );\n\n  // The page behind stays where it is.\n  React.useEffect(() => {\n    if (!mounted) return;\n    const html = document.documentElement;\n    const gap = window.innerWidth - html.clientWidth;\n    const before = {\n      overflow: html.style.overflow,\n      paddingRight: html.style.paddingRight,\n    };\n    html.style.overflow = \"hidden\";\n    if (gap > 0) html.style.paddingRight = `${gap}px`;\n    return () => {\n      html.style.overflow = before.overflow;\n      html.style.paddingRight = before.paddingRight;\n    };\n  }, [mounted]);\n\n  // Keep the background inert for the full morph, then restore focus after\n  // that inert state is released (including controlled and reduced-motion close).\n  React.useEffect(() => {\n    if (!mounted || !overlayRef.current) return;\n    const siblings = Array.from(document.body.children).filter(\n      (el): el is HTMLElement =>\n        el instanceof HTMLElement &&\n        el !== overlayRef.current &&\n        !el.contains(overlayRef.current),\n    );\n    const before = siblings.map((el) => el.inert);\n    siblings.forEach((el) => {\n      el.inert = true;\n    });\n    return () =>\n      siblings.forEach((el, i) => {\n        el.inert = before[i];\n      });\n  }, [mounted, portal]);\n\n  React.useEffect(() => {\n    if (mounted) {\n      wasMounted.current = true;\n      return;\n    }\n    if (!wasMounted.current) return;\n    wasMounted.current = false;\n    const back = returnFocus.current;\n    const frame = requestAnimationFrame(() =>\n      (back && document.contains(back) ? back : triggerRef.current)?.focus({\n        preventScroll: true,\n      }),\n    );\n    return () => cancelAnimationFrame(frame);\n  }, [mounted]);\n\n  React.useEffect(() => {\n    if (!mounted) return;\n    const measure = () =>\n      setViewport({\n        width: document.documentElement.clientWidth,\n        height: window.visualViewport?.height ?? window.innerHeight,\n        top: window.visualViewport?.offsetTop ?? 0,\n      });\n    measure();\n    window.addEventListener(\"resize\", measure);\n    window.visualViewport?.addEventListener(\"resize\", measure);\n    window.visualViewport?.addEventListener(\"scroll\", measure);\n    return () => {\n      window.removeEventListener(\"resize\", measure);\n      window.visualViewport?.removeEventListener(\"resize\", measure);\n      window.visualViewport?.removeEventListener(\"scroll\", measure);\n    };\n  }, [mounted]);\n\n  // Switching preferences during a transition settles it without clearing the query.\n  React.useLayoutEffect(() => {\n    if (!reduced) return;\n    failureMotion.current?.cancel();\n    window.clearTimeout(timer.current);\n    // Settle an in-flight animation immediately when the OS preference changes.\n\n    setPhase((value) =>\n      value === \"closing\" ? \"closed\" : value === \"opening\" ? \"open\" : value,\n    );\n    setLeaving((value) => (value.length ? [] : value));\n  }, [reduced]);\n\n  // The list's height, so the panel can ease to fit the results.\n  React.useLayoutEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const measure = () =>\n      setListHeight((h) => (h === el.offsetHeight ? h : el.offsetHeight));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [mounted, portal]);\n\n  React.useLayoutEffect(() => {\n    const el = footerRef.current;\n    if (!el) return;\n    const measure = () => setFooterHeight(el.getBoundingClientRect().height);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [mounted, portal]);\n\n  // Keep the active row in range as the results change.\n  React.useEffect(() => {\n    // Clamp keyboard selection after an external catalog update.\n\n    setActive((a) => Math.min(a, Math.max(0, rows.length - 1)));\n  }, [rows.length]);\n\n  // The highlight glides to the active row; the list scrolls only as far as it must.\n  React.useLayoutEffect(() => {\n    const row = rowRefs.current[active];\n    const list = listRef.current;\n    const pane = paneRef.current;\n    if (!row || !list || !pane) return;\n    // The highlight belongs to this pane, so it shares the rows' coordinates\n    // and slide transform. Scrolling still uses the full list-content offset.\n    const top = row.offsetTop;\n    const listTop = pane.offsetTop + top;\n    setHighlight((h) =>\n      h.top === top && h.ready && h.depth === stack.length\n        ? h\n        : {\n            top,\n            // A new page takes its initial position without gliding from the\n            // previous page. Subsequent selection changes still animate.\n            ready: h.depth === stack.length && (h.ready || isOpen),\n            depth: stack.length,\n          },\n    );\n    if (active === 0) list.scrollTop = 0;\n    else if (listTop < list.scrollTop) list.scrollTop = listTop - 4;\n    else if (listTop + ROW > list.scrollTop + list.clientHeight)\n      list.scrollTop = listTop + ROW - list.clientHeight + 4;\n  }, [active, rows, isOpen, listHeight, stack.length]);\n\n  // A new page slides in from the side you went.\n  React.useLayoutEffect(() => {\n    if (!enterDir || reduced) return;\n    const animation = paneRef.current?.animate(\n      [\n        {\n          opacity: 0,\n          filter: \"blur(4px)\",\n          transform: `translateX(${enterDir * 28}px)`,\n        },\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n      ],\n      { duration: 360, easing: EASE, delay: 40, fill: \"backwards\" },\n    );\n    return () => animation?.cancel();\n  }, [stack.length, enterDir, reduced]);\n\n  React.useEffect(() => {\n    if (!leaving.length) return;\n    const t = window.setTimeout(() => setLeaving([]), 260);\n    return () => window.clearTimeout(t);\n  }, [leaving]);\n\n  const go = (dir: 1 | -1, next: Page[]) => {\n    if (!reduced)\n      setLeaving((l) => [\n        ...l,\n        {\n          key: ++leaveKey.current,\n          rows,\n          active,\n          dir,\n          top: paneRef.current?.offsetTop ?? 0,\n        },\n      ]);\n    setEnterDir(dir);\n    setStack(next);\n    setQuery(\"\");\n    setActive(0);\n    setHighlight((h) => ({ ...h, ready: false }));\n  };\n\n  const choose = async (i: number) => {\n    const row = rows[i];\n    if (!row || run) return;\n    const { item } = row;\n    setActive(i);\n    if (item.items)\n      return go(1, [\n        ...stack,\n        { title: item.label.replace(/…$/, \"\"), items: item.items },\n      ]);\n    if (item.href && !item.onSelect) {\n      setOpen(false);\n      window.location.assign(item.href);\n      return;\n    }\n    commandRun.current?.abort();\n    const controller = new AbortController();\n    failureMotion.current?.cancel();\n    commandRun.current = controller;\n    setError(\"\");\n    try {\n      const result = item.onSelect?.({ signal: controller.signal });\n      if (!result) return setOpen(false);\n      setRun({ id: item.id, state: \"pending\" });\n      await result;\n      if (controller.signal.aborted) return;\n      setRun({ id: item.id, state: \"done\" });\n      timer.current = window.setTimeout(\n        () => setOpen(false),\n        reduced ? 0 : 520,\n      );\n    } catch {\n      if (controller.signal.aborted) return;\n      setError(`${item.label} failed. Try again.`);\n      setRun(null);\n      if (!reduced)\n        failureMotion.current =\n          rowRefs.current[i]?.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          ) ?? null;\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.nativeEvent.isComposing) return;\n    if (e.key === \"Tab\") {\n      const controls = Array.from(\n        overlayRef.current?.querySelectorAll<HTMLElement>(\n          'input, button:not(:disabled), a[href], [tabindex=\"0\"]',\n        ) ?? [],\n      ).filter(\n        (el) => !el.closest(\"[inert]\") && el.getClientRects().length > 0,\n      );\n      const index = controls.indexOf(document.activeElement as HTMLElement);\n      e.preventDefault();\n      controls[\n        (index + (e.shiftKey ? -1 : 1) + controls.length) % controls.length\n      ]?.focus();\n      return;\n    }\n    if (e.key !== \"Escape\" && e.target !== inputRef.current) return;\n    const moves: Record<string, number> = {\n      ArrowDown: active + 1,\n      ArrowUp: active - 1,\n      Home: 0,\n      End: rows.length - 1,\n    };\n    if (e.key in moves && rows.length) {\n      if ((e.key === \"Home\" || e.key === \"End\") && query) return;\n      e.preventDefault();\n      setActive((moves[e.key] + rows.length) % rows.length);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      choose(active);\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      if (query) setQuery(\"\");\n      else if (stack.length) go(-1, stack.slice(0, -1));\n      else setOpen(false);\n    } else if (e.key === \"Backspace\" && !query && stack.length) {\n      e.preventDefault();\n      go(-1, stack.slice(0, -1));\n    }\n  };\n\n  const duration = phase === \"closing\" ? CLOSE_DURATION : OPEN_DURATION;\n  const final = mounted && portal ? target() : null;\n  const shape = isOpen || !from ? final : from;\n  const listVisible = Math.min(\n    listHeight,\n    LIST_MAX,\n    Math.max(\n      40,\n      (viewport.height || 600) -\n        ((final?.top ?? 0) - viewport.top) -\n        52 -\n        footerHeight -\n        28,\n    ),\n  );\n  const panelHeight = 52 + (listVisible ? listVisible + 12 : 0) + footerHeight;\n  const empty = rows.length === 0;\n\n  const triggerContent = (\n    <>\n      {triggerIcon && (\n        <span\n          aria-hidden=\"true\"\n          className=\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4 [&_img]:size-4\"\n        >\n          {triggerIcon}\n        </span>\n      )}\n      {triggerLabel}\n    </>\n  );\n\n  const panel =\n    mounted && final && shape ? (\n      <div\n        ref={overlayRef}\n        className=\"cavemio-command-palette fixed inset-0 z-[100]\"\n        onKeyDown={onKeyDown}\n        data-phase={phase}\n      >\n        {/* A faint veil over the page; clicking it closes. */}\n        <div\n          aria-hidden=\"true\"\n          onPointerDown={() => setOpen(false)}\n          className=\"absolute inset-0 bg-background/55\"\n          style={{\n            opacity: isOpen ? 1 : 0,\n            transition: reduced ? \"none\" : `opacity ${duration}ms ${EASE}`,\n          }}\n        />\n        <div\n          role=\"dialog\"\n          aria-modal=\"true\"\n          aria-label=\"Command palette\"\n          className=\"absolute overflow-hidden bg-popover text-popover-foreground\"\n          style={{\n            left: shape.left,\n            top: shape.top,\n            width: shape.width,\n            height: isOpen || !from ? panelHeight : from.height,\n            borderRadius: shape.radius,\n            backgroundColor: !isOpen && from ? \"transparent\" : \"var(--popover)\",\n            boxShadow: \"inset 0 0 0 1px var(--border)\",\n            opacity: !from && !isOpen ? 0 : 1,\n            transform:\n              !from && !isOpen ? \"scale(0.97) translateY(-6px)\" : \"none\",\n            transition: reduced\n              ? \"none\"\n              : `left ${duration}ms ${EASE}, top ${duration}ms ${EASE}, width ${duration}ms ${EASE}, height ${duration}ms ${EASE}, border-radius ${duration}ms ${EASE}, background-color ${duration}ms ${EASE}, opacity 240ms ${EASE}, transform ${duration}ms ${EASE}`,\n          }}\n        >\n          {(run || error) && (\n            <p role=\"status\" className=\"sr-only\">\n              {run\n                ? `${rows.find((row) => row.item.id === run.id)?.item.label ?? \"Command\"}: ${run.state === \"pending\" ? \"Running\" : \"Complete\"}`\n                : error}\n            </p>\n          )}\n          {/* The pill's face, hotkey and all, fading as the panel grows out of it and back as it folds in. */}\n          {from && (\n            <span\n              aria-hidden=\"true\"\n              data-command-trigger-copy=\"\"\n              className=\"pointer-events-none absolute left-0 top-0 flex items-center whitespace-nowrap\"\n              style={{\n                height: from.height,\n                opacity: isOpen ? 0 : 1,\n                transition: reduced\n                  ? \"none\"\n                  : isOpen\n                    ? \"opacity 100ms ease-out\"\n                    : \"opacity 140ms ease-out 160ms\",\n              }}\n            >\n              <Kbd hotkey={hotkey} label={triggerContent} />\n            </span>\n          )}\n\n          <div\n            inert={phase === \"closing\"}\n            className=\"absolute left-0 top-0 flex flex-col\"\n            style={{\n              width: final.width,\n              opacity: isOpen ? 1 : 0,\n              transition: reduced\n                ? \"none\"\n                : isOpen\n                  ? \"opacity 240ms ease-out 60ms\"\n                  : \"opacity 100ms ease-out\",\n            }}\n          >\n            <div className=\"flex h-[52px] shrink-0 items-center gap-2.5 border-b border-border px-5\">\n              <SearchIcon className=\"text-muted-foreground\" />\n              {/* Where you are, as a chip that opens in when you go into a list. */}\n              <span\n                className=\"grid shrink-0\"\n                style={{\n                  gridTemplateColumns: page ? \"1fr\" : \"0fr\",\n                  opacity: page ? 1 : 0,\n                  transition: reduced\n                    ? \"none\"\n                    : `grid-template-columns 360ms ${EASE}, opacity 240ms ${EASE}`,\n                }}\n              >\n                <span className=\"min-w-0 overflow-hidden\">\n                  <span className=\"mr-0.5 flex h-6 items-center whitespace-nowrap rounded-md bg-muted px-2 text-[12px] font-medium text-foreground\">\n                    <TextMorph>{page?.title ?? \"\"}</TextMorph>\n                  </span>\n                </span>\n              </span>\n              <input\n                ref={inputRef}\n                value={query}\n                onChange={(e) => {\n                  setQuery(e.target.value);\n                  setActive(0);\n                }}\n                placeholder={\n                  page ? `Search ${page.title.toLowerCase()}…` : placeholder\n                }\n                aria-label={searchLabel}\n                role=\"combobox\"\n                aria-expanded=\"true\"\n                aria-controls={`${id}-list`}\n                aria-activedescendant={\n                  rows.length ? `${id}-opt-${active}` : undefined\n                }\n                aria-autocomplete=\"list\"\n                spellCheck={false}\n                className=\"h-full min-w-0 flex-1 bg-transparent text-[14.5px] text-foreground outline-none placeholder:text-muted-foreground\"\n              />\n              <button\n                ref={closeRef}\n                type=\"button\"\n                aria-label=\"Close search\"\n                onClick={() => setOpen(false)}\n                className={`rounded-md p-1 ${FOCUS}`}\n              >\n                <Kbd>esc</Kbd>\n              </button>\n            </div>\n\n            <div\n              ref={listRef}\n              className=\"relative overflow-y-auto overscroll-contain px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n              style={{\n                height: listVisible ? listVisible + 12 : 0,\n                transition:\n                  reduced || !isOpen ? \"none\" : `height 300ms ${EASE}`,\n              }}\n            >\n              <div ref={innerRef} className=\"relative py-1.5\">\n                {!page && filters.length > 0 && (\n                  <div\n                    className=\"border-b border-border px-3 py-2\"\n                    data-command-filters=\"\"\n                  >\n                    <p className=\"mb-1 text-[12px] font-medium text-muted-foreground\">\n                      {filtersLabel}\n                    </p>\n                    <div className=\"flex flex-wrap gap-2\">\n                      {filters.map((filter) => (\n                        <button\n                          key={filter.id}\n                          type=\"button\"\n                          aria-label={`Remove ${filter.label} filter`}\n                          disabled={!onFilterRemove}\n                          onClick={() => {\n                            inputRef.current?.focus({ preventScroll: true });\n                            onFilterRemove?.(filter.id);\n                            setActive(0);\n                          }}\n                          className={`flex min-h-8 max-w-full items-center gap-1.5 rounded-lg bg-muted px-2.5 text-[12px] text-muted-foreground enabled:cursor-pointer enabled:hover:bg-accent enabled:hover:text-foreground ${FOCUS}`}\n                        >\n                          {filter.icon && (\n                            <span\n                              aria-hidden=\"true\"\n                              className=\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4 [&_img]:size-4\"\n                            >\n                              {filter.icon}\n                            </span>\n                          )}\n                          <span className=\"truncate\">{filter.label}</span>\n                          {onFilterRemove && (\n                            <svg\n                              aria-hidden=\"true\"\n                              viewBox=\"0 0 16 16\"\n                              fill=\"none\"\n                              className=\"size-3.5 shrink-0\"\n                            >\n                              <path\n                                d=\"m4 4 8 8M12 4l-8 8\"\n                                stroke=\"currentColor\"\n                                strokeWidth=\"1.5\"\n                                strokeLinecap=\"round\"\n                              />\n                            </svg>\n                          )}\n                        </button>\n                      ))}\n                    </div>\n                  </div>\n                )}\n                {leaving.map((l) => (\n                  <LeavingPane key={l.key} dir={l.dir} top={l.top}>\n                    <Results\n                      rows={l.rows}\n                      active={l.active}\n                      run={null}\n                      reduced={reduced}\n                    />\n                  </LeavingPane>\n                ))}\n                <div ref={paneRef} key={stack.length} className=\"relative\">\n                  {!empty && (\n                    <span\n                      aria-hidden=\"true\"\n                      data-command-highlight=\"\"\n                      className=\"absolute inset-x-0 rounded-xl bg-accent\"\n                      style={{\n                        top: 0,\n                        height: ROW,\n                        transform: `translateY(${highlight.top}px)`,\n                        transition:\n                          highlight.ready && !reduced\n                            ? `transform 200ms ${EASE}`\n                            : \"none\",\n                      }}\n                    />\n                  )}\n                  <Results\n                    rows={rows}\n                    active={active}\n                    run={run}\n                    reduced={reduced}\n                    listId={`${id}-list`}\n                    optionId={(i) => `${id}-opt-${i}`}\n                    setActive={(i) => !run && setActive(i)}\n                    choose={choose}\n                    rowRefs={rowRefs}\n                  />\n                </div>\n                {empty && (\n                  <p\n                    role=\"status\"\n                    className=\"flex min-w-0 items-center overflow-hidden px-3 text-[13px] text-muted-foreground\"\n                    style={{ height: ROW }}\n                  >\n                    <TextMorph>\n                      {query\n                        ? `No results for “${query.trim()}”`\n                        : \"Nothing here yet\"}\n                    </TextMorph>\n                  </p>\n                )}\n              </div>\n            </div>\n\n            <div\n              ref={footerRef}\n              className=\"flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-x-6 gap-y-3 border-t border-border px-5 py-3 text-[12px] text-muted-foreground\"\n            >\n              <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2\">\n                <span className=\"flex items-center gap-1.5\">\n                  <Kbd>↑</Kbd>\n                  <Kbd>↓</Kbd>Navigate\n                </span>\n                <span className=\"flex items-center gap-1.5\">\n                  <Kbd>↵</Kbd>Select\n                </span>\n                {page && (\n                  <span className=\"flex items-center gap-1.5\">\n                    <Kbd>⌫</Kbd>Back\n                  </span>\n                )}\n              </div>\n              {footer && (\n                <div\n                  data-command-footer=\"\"\n                  className=\"min-w-0 leading-5 [&_a]:underline [&_a]:underline-offset-4 [&_a:focus-visible]:outline [&_a:focus-visible]:outline-2 [&_a:focus-visible]:outline-ring\"\n                >\n                  {footer}\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n    ) : null;\n\n  return (\n    <div className={`inline-flex ${className}`} {...props}>\n      <Kbd\n        ref={triggerRef}\n        hotkey={hotkey}\n        label={triggerContent}\n        onTrigger={(event) =>\n          setOpen(\n            event.type === \"click\" ||\n              !(phase === \"open\" || phase === \"opening\"),\n          )\n        }\n        stopPropagation\n        hidden={!showTrigger}\n        aria-label={triggerLabel}\n        aria-haspopup=\"dialog\"\n        aria-expanded={isOpen}\n        style={{\n          display: showTrigger ? undefined : \"none\",\n          opacity: mounted ? 0 : 1,\n          transition:\n            mounted || reduced || from ? \"none\" : \"opacity 160ms ease-out\",\n        }}\n      />\n      {portal && panel ? createPortal(panel, portal) : null}\n    </div>\n  );\n}\n\n/** The page you left: it slides out toward the side you came from, then removes itself. */\nfunction LeavingPane({\n  dir,\n  top,\n  children,\n}: {\n  dir: number;\n  top: number;\n  children: React.ReactNode;\n}) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const animation = ref.current?.animate(\n      [\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n        {\n          opacity: 0,\n          filter: \"blur(4px)\",\n          transform: `translateX(${-dir * 28}px)`,\n        },\n      ],\n      { duration: 240, easing: EASE, fill: \"forwards\" },\n    );\n    return () => animation?.cancel();\n  }, [dir]);\n  return (\n    <div\n      ref={ref}\n      inert\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute inset-x-0\"\n      style={{ top }}\n    >\n      {children}\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/kbd.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface KbdProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  /** Individual keycaps, e.g. [\"mod\", \"k\"] or \"mod+shift+p\". Display only. */\n  keys?: string[] | string;\n  /** Reflect physical keys while held, without claiming their keyboard events. */\n  live?: boolean;\n  /** Size of individual keycaps supplied by keys (or live hotkey feedback). */\n  size?: \"sm\" | \"md\";\n  /** Key combined with Cmd or Ctrl, e.g. \"k\". Empty/omitted disables the shortcut. */\n  hotkey?: string;\n  /** Optional action label displayed beside the keycap in a full-size surface. */\n  label?: React.ReactNode;\n  /** Called once by a click, Enter/Space, or the matching global shortcut. */\n  onTrigger?: (\n    event: KeyboardEvent | React.MouseEvent<HTMLButtonElement>,\n  ) => void;\n  /** Stop clicks and matching shortcuts before they reach ancestor handlers. */\n  stopPropagation?: boolean;\n  /** Button ref, available when onTrigger is supplied. */\n  ref?: React.Ref<HTMLButtonElement>;\n}\n\nconst subscribePlatform = () => () => {};\nconst isMac = () =>\n  /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);\nconst keycap =\n  \"inline-flex h-5 min-w-5 shrink-0 items-center justify-center gap-0.5 whitespace-nowrap rounded bg-background px-1.5 [font-family:inherit] text-[11px] font-medium leading-none text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\";\n\nconst labeledSurface =\n  \"inline-flex h-8 items-center justify-center gap-6 whitespace-nowrap rounded-lg bg-transparent pl-2.5 pr-1.5 [font-family:inherit] text-[14px] font-normal text-foreground/80 shadow-[inset_0_0_0_1px_var(--border)]\";\n\nconst aliases: Record<string, string> = {\n  cmd: \"meta\",\n  command: \"meta\",\n  \"⌘\": \"meta\",\n  ctrl: \"control\",\n  \"⌃\": \"control\",\n  option: \"alt\",\n  \"⌥\": \"alt\",\n  \"⇧\": \"shift\",\n  esc: \"escape\",\n  return: \"enter\",\n  \"↵\": \"enter\",\n  spacebar: \" \",\n  space: \" \",\n  \"␣\": \" \",\n  up: \"arrowup\",\n  \"↑\": \"arrowup\",\n  down: \"arrowdown\",\n  \"↓\": \"arrowdown\",\n  left: \"arrowleft\",\n  \"←\": \"arrowleft\",\n  right: \"arrowright\",\n  \"→\": \"arrowright\",\n  \"⌫\": \"backspace\",\n  \"⇥\": \"tab\",\n};\nfunction normalizeKey(value: string) {\n  const lower = value === \" \" ? value : value.trim().toLowerCase();\n  return aliases[lower] ?? lower;\n}\nfunction resolvedKey(key: string, mac: boolean) {\n  return key === \"mod\" ? (mac ? \"meta\" : \"control\") : key;\n}\nfunction keyLabel(key: string, mac: boolean, spoken = false): string {\n  const resolved = resolvedKey(key, mac);\n  const words: Record<string, string> = {\n    meta: mac ? \"Command\" : \"Windows\",\n    control: \"Control\",\n    alt: mac ? \"Option\" : \"Alt\",\n    shift: \"Shift\",\n    enter: \"Enter\",\n    escape: \"Escape\",\n    backspace: \"Backspace\",\n    tab: \"Tab\",\n    \" \": \"Space\",\n    arrowup: \"Up arrow\",\n    arrowdown: \"Down arrow\",\n    arrowleft: \"Left arrow\",\n    arrowright: \"Right arrow\",\n  };\n  if (spoken) return words[resolved] ?? key.toUpperCase();\n  const symbols: Record<string, string> = {\n    meta: mac ? \"⌘\" : \"Win\",\n    control: mac ? \"⌃\" : \"Ctrl\",\n    alt: mac ? \"⌥\" : \"Alt\",\n    shift: mac ? \"⇧\" : \"Shift\",\n    enter: mac ? \"↵\" : \"Enter\",\n    escape: \"Esc\",\n    backspace: mac ? \"⌫\" : \"Backspace\",\n    tab: mac ? \"⇥\" : \"Tab\",\n    \" \": \"Space\",\n    arrowup: \"↑\",\n    arrowdown: \"↓\",\n    arrowleft: \"←\",\n    arrowright: \"→\",\n  };\n  return symbols[resolved] ?? key.toUpperCase();\n}\n\nfunction KeySequence({\n  keys,\n  mac,\n  live,\n  size,\n}: {\n  keys: string[];\n  mac: boolean | null;\n  live: boolean;\n  size: \"sm\" | \"md\";\n}) {\n  const [held, setHeld] = React.useState<string[]>([]);\n  React.useEffect(() => {\n    if (!live) return;\n    const physicalKeys = new Map<string, string>();\n    const clear = () => {\n      physicalKeys.clear();\n      setHeld([]);\n    };\n    const update = (event: KeyboardEvent) => {\n      if (event.isComposing || event.key === \"Unidentified\") return;\n      const key = normalizeKey(event.key);\n      const code = event.code || key;\n      if (event.type === \"keydown\") physicalKeys.set(code, key);\n      else physicalKeys.delete(code);\n      // macOS can omit letter keyup events during Command chords.\n      if (event.type === \"keyup\" && key === \"meta\") physicalKeys.clear();\n      const modifiers: Record<string, boolean> = {\n        meta: event.metaKey,\n        control: event.ctrlKey,\n        alt: event.altKey,\n        shift: event.shiftKey,\n      };\n      for (const [code, value] of physicalKeys) {\n        if (value in modifiers && !modifiers[value]) physicalKeys.delete(code);\n      }\n      const next = [\n        ...new Set([\n          ...physicalKeys.values(),\n          ...Object.keys(modifiers).filter((key) => modifiers[key]),\n        ]),\n      ];\n      setHeld((previous) =>\n        previous.length === next.length &&\n        previous.every((key) => next.includes(key))\n          ? previous\n          : next,\n      );\n    };\n    const visibility = () => {\n      if (document.hidden) clear();\n    };\n    window.addEventListener(\"keydown\", update, true);\n    window.addEventListener(\"keyup\", update, true);\n    window.addEventListener(\"blur\", clear);\n    document.addEventListener(\"visibilitychange\", visibility);\n    return () => {\n      window.removeEventListener(\"keydown\", update, true);\n      window.removeEventListener(\"keyup\", update, true);\n      window.removeEventListener(\"blur\", clear);\n      document.removeEventListener(\"visibilitychange\", visibility);\n    };\n  }, [live]);\n\n  return (\n    <span className=\"inline-flex items-center gap-1\">\n      <span className=\"sr-only\">\n        {keys\n          .map((key) =>\n            mac === null && key === \"mod\"\n              ? \"Command or Control\"\n              : keyLabel(key, mac === true, true),\n          )\n          .join(\" + \")}\n      </span>\n      {keys.map((key, index) => {\n        const pressed = live && held.includes(resolvedKey(key, mac === true));\n        return (\n          <span\n            key={`${index}:${key}`}\n            aria-hidden=\"true\"\n            data-kbd-key={key}\n            data-pressed={pressed}\n            className={`inline-grid shrink-0 items-center justify-items-center rounded-md bg-background [font-family:inherit] font-medium leading-none transition-[translate,box-shadow,color] duration-100 motion-reduce:translate-y-0 motion-reduce:transition-none ${\n              size === \"sm\"\n                ? \"h-5 min-w-5 px-1 text-[11px]\"\n                : \"h-6 min-w-6 px-1 text-[12px]\"\n            } ${\n              pressed\n                ? \"translate-y-px text-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n                : \"translate-y-0 text-muted-foreground shadow-[inset_0_0_0_1px_var(--border),inset_0_-1px_0_var(--border)]\"\n            }`}\n          >\n            <span className={mac === null ? \"invisible\" : undefined}>\n              {keyLabel(key, mac !== false)}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\n/** A passive keycap, or a native shortcut button when onTrigger is provided. */\nexport function Kbd({\n  keys,\n  live = false,\n  size = \"md\",\n  hotkey = \"\",\n  label,\n  onTrigger,\n  stopPropagation = false,\n  disabled = false,\n  children,\n  className = \"\",\n  onClick,\n  ref,\n  ...props\n}: KbdProps) {\n  const mac = React.useSyncExternalStore<boolean | null>(\n    subscribePlatform,\n    isMac,\n    () => null,\n  );\n  const key = hotkey.trim();\n  const sequence =\n    keys !== undefined\n      ? (Array.isArray(keys) ? keys : keys.split(\"+\"))\n          .map(normalizeKey)\n          .filter(Boolean)\n      : live && key\n        ? [\"mod\", normalizeKey(key)]\n        : null;\n  React.useEffect(() => {\n    if (!key || !onTrigger || disabled) return;\n    const handleKey = (event: KeyboardEvent) => {\n      if (\n        event.defaultPrevented ||\n        event.isComposing ||\n        event.key.toLowerCase() !== key.toLowerCase() ||\n        !(event.metaKey || event.ctrlKey) ||\n        event.altKey ||\n        event.shiftKey\n      )\n        return;\n      event.preventDefault();\n      if (stopPropagation) event.stopPropagation();\n      if (!event.repeat) onTrigger(event);\n    };\n    // Capture only when propagation is explicitly suppressed, before React ancestors.\n    document.addEventListener(\"keydown\", handleKey, stopPropagation);\n    return () =>\n      document.removeEventListener(\"keydown\", handleKey, stopPropagation);\n  }, [key, onTrigger, disabled, stopPropagation]);\n\n  const content = (\n    <>\n      {children}\n      {sequence ? (\n        <KeySequence\n          key={JSON.stringify([sequence, live, disabled])}\n          keys={sequence}\n          mac={mac}\n          live={live && !disabled}\n          size={size}\n        />\n      ) : (\n        key && (\n          <>\n            {/* Keep unknown platform labels hidden; size only the displayed chord. */}\n            <span\n              className={`inline-flex items-center justify-center gap-0.5 ${mac === null ? \"invisible\" : \"\"}`}\n              aria-hidden=\"true\"\n            >\n              <span>{mac === false ? \"Ctrl\" : \"⌘\"}</span>\n              {mac === false && <span>+</span>}\n              <span>{key.toUpperCase()}</span>\n            </span>\n            <span className=\"sr-only\">\n              {mac === null\n                ? \"Command or Control\"\n                : mac\n                  ? \"Command\"\n                  : \"Control\"}{\" \"}\n              + {key.toUpperCase()}\n            </span>\n          </>\n        )\n      )}\n    </>\n  );\n  const hasLabel = label != null;\n  const face = (\n    <>\n      {hasLabel && (\n        <span className=\"inline-flex items-center gap-2\">{label}</span>\n      )}\n      {(sequence || key || children || !hasLabel) && (\n        <kbd\n          className={\n            hasLabel && !sequence\n              ? keycap\n              : \"inline-flex items-center gap-0.5 [font-family:inherit]\"\n          }\n        >\n          {content}\n        </kbd>\n      )}\n    </>\n  );\n  if (!onTrigger && hasLabel) {\n    return (\n      <span className={`${labeledSurface} ${className}`} {...props}>\n        {face}\n      </span>\n    );\n  }\n  if (!onTrigger) {\n    return (\n      <kbd\n        className={`${sequence ? \"inline-flex items-center gap-1\" : keycap} ${className}`}\n        {...props}\n      >\n        {content}\n      </kbd>\n    );\n  }\n  return (\n    <button\n      {...props}\n      ref={ref}\n      type={props.type ?? \"button\"}\n      disabled={disabled}\n      aria-keyshortcuts={key ? `Meta+${key} Control+${key}` : undefined}\n      className={`${hasLabel ? labeledSurface : sequence ? \"inline-flex items-center gap-1 rounded-md\" : keycap} relative cursor-pointer hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 after:absolute ${hasLabel ? \"after:-inset-y-1.5 after:inset-x-0\" : \"after:-inset-y-3 after:-inset-x-2\"} ${className}`}\n      onClick={(event) => {\n        if (stopPropagation) event.stopPropagation();\n        onClick?.(event);\n        if (!event.defaultPrevented) onTrigger(event);\n      }}\n    >\n      {face}\n    </button>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "license": "MIT",
    "cavemio": {
      "component": "command-palette",
      "exportName": "CommandPalette",
      "usage": "import { CommandPalette } from \"@/components/command-palette\";\n\n<CommandPalette items={[{ id: \"home\", label: \"Home\", href: \"/\" }]} />",
      "accessibility": "Modal dialog with combobox/listbox semantics, keyboard navigation, focus return, inert background, and live reduced motion. Commands receive an abort signal; stale completions cannot close a new session.",
      "previewNote": "Demo commands run locally; callers supply items and navigation callbacks.",
      "categories": [
        "interactions"
      ],
      "documentation": {
        "useCases": [],
        "features": [],
        "states": [],
        "props": [
          {
            "name": "items",
            "type": "CommandItem[]",
            "description": "Required. Commands and groups available in the palette."
          },
          {
            "name": "placeholder",
            "type": "string",
            "description": "Optional. Placeholder shown in the search field."
          },
          {
            "name": "searchLabel",
            "type": "string",
            "description": "Optional. Accessible name of the search field."
          },
          {
            "name": "hotkey",
            "type": "string",
            "description": "Optional. The letter that opens it with ⌘ (or Ctrl). Empty string turns the hotkey off."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Optional. Controlled open state of the palette."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "description": "Optional. Initial open state when uncontrolled."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Optional. Called when the palette opens or closes."
          },
          {
            "name": "showTrigger",
            "type": "boolean",
            "description": "Optional. Show the command menu trigger. Without it, open with the hotkey or the open prop."
          },
          {
            "name": "triggerLabel",
            "type": "string",
            "description": "Optional. The pill's words."
          },
          {
            "name": "triggerIcon",
            "type": "React.ReactNode",
            "description": "Optional. Leading trigger icon. Defaults to search; pass null to omit it."
          },
          {
            "name": "filters",
            "type": "CommandFilter[]",
            "description": "Optional. Caller-owned active search scopes. The caller also filters items."
          },
          {
            "name": "filtersLabel",
            "type": "string",
            "description": "Optional. Accessible name for the active search filters."
          },
          {
            "name": "onFilterRemove",
            "type": "(id: string) => void",
            "description": "Optional. Called with the ID of a removed filter."
          },
          {
            "name": "footer",
            "type": "React.ReactNode",
            "description": "Optional. Help copy, links, or actions alongside the keyboard hints."
          }
        ]
      }
    }
  },
  "type": "registry:component"
}