{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "launcher",
  "type": "registry:component",
  "title": "Launcher",
  "description": "A search button that grows into a keyboard launcher, with grouped actions and their shortcuts, your recent picks, a highlight that glides as you arrow through and pages you can step into.",
  "categories": [
    "component",
    "Workspace"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/kbd.json"
  ],
  "files": [
    {
      "path": "registry/devl/launcher.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ChevronRightIcon, CircleCheckIcon, CornerDownLeftIcon, HistoryIcon, SearchIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LauncherAction {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  group?: string;\n  shortcut?: string[];\n  keywords?: string[];\n  page?: string;\n  done?: string;\n  onSelect?: () => void;\n}\n\nexport interface LauncherPage {\n  title: string;\n  placeholder?: string;\n  actions: LauncherAction[];\n}\n\nexport interface LauncherProps {\n  actions: LauncherAction[];\n  pages?: Record<string, LauncherPage>;\n  defaultRecent?: string[];\n  recentLimit?: number;\n  placeholder?: string;\n  triggerLabel?: string;\n  defaultOpen?: boolean;\n  className?: string;\n}\n\ntype Section = { title: string; items: LauncherAction[] };\n\nfunction scrollListOnly(list: HTMLElement, el: HTMLElement) {\n  const top = el.getBoundingClientRect().top - list.getBoundingClientRect().top + list.scrollTop;\n  if (top < list.scrollTop) list.scrollTop = top - 6;\n  else if (top + el.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = top + el.offsetHeight - list.clientHeight + 6;\n}\n\nfunction score(action: LauncherAction, q: string) {\n  const label = action.label.toLowerCase();\n  if (label.startsWith(q)) return 3;\n  if (label.split(/\\s+/).some((w) => w.startsWith(q))) return 2;\n  if (label.includes(q) || action.keywords?.some((k) => k.toLowerCase().includes(q))) return 1;\n  return 0;\n}\n\nfunction useThemeRadius(scale: number) {\n  const probe = useRef<HTMLSpanElement>(null);\n  const [radius, setRadius] = useState(scale * 10);\n  useLayoutEffect(() => {\n    const el = probe.current;\n    if (!el) return;\n    const read = () => setRadius(el.offsetWidth);\n    read();\n    const observer = new ResizeObserver(read);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n  const marker = (\n    <span\n      aria-hidden\n      className=\"pointer-events-none invisible absolute h-0\"\n      ref={probe}\n      style={{ width: `calc(var(--radius) * ${scale})` }}\n    />\n  );\n  return [radius, marker] as const;\n}\n\nexport function Launcher({\n  actions,\n  pages = {},\n  defaultRecent = [],\n  recentLimit = 3,\n  placeholder = \"Type a command or search…\",\n  triggerLabel = \"Search or jump to…\",\n  defaultOpen = false,\n  className,\n}: LauncherProps) {\n  const [radius, radiusProbe] = useThemeRadius(1);\n  const [open, setOpen] = useState(defaultOpen);\n  const [page, setPage] = useState<string | null>(null);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n  const [recent, setRecent] = useState(defaultRecent);\n  const [lastRun, setLastRun] = useState<string | null>(null);\n  const [direction, setDirection] = useState(1);\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const timer = useRef<number>(undefined);\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const all = useMemo(() => {\n    const map = new Map<string, LauncherAction>();\n    for (const a of actions) map.set(a.id, a);\n    for (const p of Object.values(pages)) for (const a of p.actions) map.set(a.id, a);\n    return map;\n  }, [actions, pages]);\n\n  const sections: Section[] = useMemo(() => {\n    const q = query.trim().toLowerCase();\n    const source = page ? (pages[page]?.actions ?? []) : actions;\n    if (q) {\n      const hits = source\n        .map((a) => ({ a, s: score(a, q) }))\n        .filter((x) => x.s > 0)\n        .sort((x, y) => y.s - x.s)\n        .map((x) => x.a);\n      return hits.length ? [{ title: page ? (pages[page]?.title ?? \"Results\") : \"Results\", items: hits }] : [];\n    }\n    if (page) return [{ title: pages[page]?.title ?? \"\", items: source }];\n    const out: Section[] = [];\n    const recents = recent.map((id) => all.get(id)).filter((a): a is LauncherAction => !!a);\n    if (recents.length) out.push({ title: \"Recent\", items: recents });\n    for (const a of source) {\n      const title = a.group ?? \"Actions\";\n      const section = out.find((s) => s.title === title && s.title !== \"Recent\");\n      if (section) section.items.push(a);\n      else out.push({ title, items: [a] });\n    }\n    return out;\n  }, [query, page, pages, actions, recent, all]);\n\n  const flat = sections.flatMap((s) => s.items.map((item) => ({ item, section: s.title })));\n  const current = flat[Math.min(active, flat.length - 1)];\n  const optionId = (section: string, id: string) => `${baseId}-${section}-${id}`.replace(/[^a-zA-Z0-9_-]/g, \"\");\n\n  useEffect(() => setActive(0), [query, page]);\n\n  useEffect(() => {\n    const list = listRef.current;\n    if (!current || !list) return;\n    const el = list.querySelector<HTMLElement>(`#${CSS.escape(optionId(current.section, current.item.id))}`);\n    if (el) scrollListOnly(list, el);\n  }, [active, current?.item.id]);\n\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  const [more, setMore] = useState(false);\n  const checkMore = () => {\n    const el = listRef.current;\n    if (el) setMore(el.scrollTop + el.clientHeight < el.scrollHeight - 2);\n  };\n  useLayoutEffect(() => {\n    const el = listRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      setHeight(Math.min(el.scrollHeight, 296));\n      checkMore();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [open]);\n\n  const focusOnOpen = useRef(false);\n  const show = () => {\n    focusOnOpen.current = true;\n    setOpen(true);\n    setLastRun(null);\n  };\n  useEffect(() => {\n    if (open && focusOnOpen.current) {\n      focusOnOpen.current = false;\n      inputRef.current?.focus();\n    }\n  }, [open]);\n  const close = () => {\n    setOpen(false);\n    setPage(null);\n    setQuery(\"\");\n    requestAnimationFrame(() => triggerRef.current?.focus());\n  };\n  const goTo = (next: string | null) => {\n    setDirection(next ? 1 : -1);\n    setPage(next);\n    setQuery(\"\");\n    inputRef.current?.focus();\n  };\n\n  const run = (action: LauncherAction) => {\n    if (action.page) return goTo(action.page);\n    action.onSelect?.();\n    setRecent((list) => [action.id, ...list.filter((id) => id !== action.id)].slice(0, recentLimit));\n    setLastRun(action.done ?? action.label);\n    clearTimeout(timer.current);\n    timer.current = window.setTimeout(() => setLastRun(null), 2600);\n    close();\n  };\n\n  const onInputKey = (event: KeyboardEvent<HTMLInputElement>) => {\n    const n = flat.length;\n    if (event.key === \"ArrowDown\" && n) {\n      event.preventDefault();\n      setActive((i) => (i + 1) % n);\n    } else if (event.key === \"ArrowUp\" && n) {\n      event.preventDefault();\n      setActive((i) => (i - 1 + n) % n);\n    } else if (event.key === \"Home\" && n) {\n      event.preventDefault();\n      setActive(0);\n    } else if (event.key === \"End\" && n) {\n      event.preventDefault();\n      setActive(n - 1);\n    } else if (event.key === \"Enter\" && current) {\n      event.preventDefault();\n      run(current.item);\n    } else if (event.key === \"Escape\") {\n      event.preventDefault();\n      if (page) goTo(null);\n      else close();\n    } else if (event.key === \"Backspace\" && !query && page) {\n      event.preventDefault();\n      goTo(null);\n    }\n  };\n\n  const onWrapperKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === \"k\") {\n      event.preventDefault();\n      if (open) close();\n      else show();\n    }\n  };\n\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 420, damping: 36 };\n  const pageTitle = page ? pages[page]?.title : null;\n\n  return (\n    <div className={cn(\"relative w-full max-w-md\", className)} onKeyDown={onWrapperKey}>\n      {radiusProbe}\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {open ? (\n          <motion.div\n            className=\"overflow-hidden border bg-popover text-popover-foreground shadow-lg/5\"\n            exit={{ opacity: 0, transition: { duration: 0.12 } }}\n            key=\"panel\"\n            layoutId={`${baseId}-shell`}\n            style={{ borderRadius: radius * 1.6 }}\n            transition={spring}\n          >\n            <motion.div animate={{ opacity: 1 }} initial={reduce ? false : { opacity: 0 }} transition={{ delay: 0.06 }}>\n              <div className=\"flex items-center gap-2 border-b px-3\">\n                <SearchIcon className=\"size-4 shrink-0 text-muted-foreground\" />\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  {pageTitle && (\n                    <motion.button\n                      animate={{ opacity: 1, scale: 1 }}\n                      className=\"flex shrink-0 cursor-pointer items-center gap-1 rounded-md bg-accent px-1.5 py-0.5 font-medium text-xs outline-none hover:bg-accent/80\"\n                      exit={{ opacity: 0, scale: 0.8 }}\n                      initial={{ opacity: 0, scale: 0.8 }}\n                      key={pageTitle}\n                      onClick={() => goTo(null)}\n                      title=\"Back (Backspace)\"\n                      type=\"button\"\n                    >\n                      {pageTitle}\n                      <ChevronRightIcon className=\"size-3 text-muted-foreground\" />\n                    </motion.button>\n                  )}\n                </AnimatePresence>\n                <input\n                  aria-activedescendant={current ? optionId(current.section, current.item.id) : undefined}\n                  aria-autocomplete=\"list\"\n                  aria-controls={`${baseId}-list`}\n                  aria-expanded\n                  aria-label=\"Command\"\n                  className=\"h-11 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/72\"\n                  onChange={(e) => setQuery(e.target.value)}\n                  onKeyDown={onInputKey}\n                  placeholder={page ? (pages[page]?.placeholder ?? \"Search…\") : placeholder}\n                  ref={inputRef}\n                  role=\"combobox\"\n                  value={query}\n                />\n                <Kbd>esc</Kbd>\n              </div>\n\n              <motion.div animate={{ height }} className=\"overflow-hidden\" initial={false} transition={spring}>\n                <div\n                  aria-label=\"Results\"\n                  className={cn(\n                    \"max-h-74 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n                    more && \"mask-[linear-gradient(to_bottom,black_calc(100%-32px),transparent)]\",\n                  )}\n                  onScroll={checkMore}\n                  ref={listRef}\n                  role=\"region\"\n                  tabIndex={0}\n                >\n                  <AnimatePresence custom={direction} initial={false} mode=\"popLayout\">\n                    <motion.div\n                      animate={{ x: 0, opacity: 1 }}\n                      custom={direction}\n                      exit={{ x: reduce ? 0 : -24 * direction, opacity: 0, transition: { duration: 0.12 } }}\n                      id={`${baseId}-list`}\n                      initial={reduce ? false : { x: 24 * direction, opacity: 0 }}\n                      key={page ?? \"root\"}\n                      role=\"listbox\"\n                      className=\"p-1.5\"\n                      transition={spring}\n                    >\n                      {sections.length === 0 && (\n                        <p className=\"px-3 py-8 text-center text-muted-foreground text-sm\">\n                          Nothing for “{query}”.\n                        </p>\n                      )}\n                      {sections.map((section) => (\n                        <div aria-label={section.title} className=\"not-first:mt-1.5\" key={section.title} role=\"group\">\n                          <div className=\"flex items-center gap-1.5 px-2 py-1.5 font-medium text-muted-foreground text-xs\">\n                            {section.title === \"Recent\" && <HistoryIcon className=\"size-3\" />}\n                            {section.title}\n                          </div>\n                          {section.items.map((item) => {\n                            const isActive = current?.item.id === item.id && current.section === section.title;\n                            const index = flat.findIndex((f) => f.item.id === item.id && f.section === section.title);\n                            return (\n                              <div\n                                aria-selected={isActive}\n                                className={cn(\n                                  \"relative flex h-9 cursor-pointer select-none items-center gap-2.5 rounded-lg px-2 text-sm [&_svg:not([class*='size-'])]:size-4\",\n                                  isActive ? \"text-foreground\" : \"text-foreground/80\",\n                                )}\n                                id={optionId(section.title, item.id)}\n                                key={item.id}\n                                onClick={() => run(item)}\n                                onMouseMove={() => index !== active && setActive(index)}\n                                role=\"option\"\n                                tabIndex={-1}\n                              >\n                                {isActive && (\n                                  <motion.span\n                                    className=\"absolute inset-0 rounded-lg bg-accent\"\n                                    layoutId={`${baseId}-glide`}\n                                    transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 40 }}\n                                  />\n                                )}\n                                <span className=\"relative flex shrink-0 text-muted-foreground\">{item.icon}</span>\n                                <span className=\"relative min-w-0 flex-1 truncate\">{item.label}</span>\n                                {item.page ? (\n                                  <ChevronRightIcon className=\"relative size-4 text-muted-foreground\" />\n                                ) : (\n                                  item.shortcut && (\n                                    <KbdGroup className=\"relative\">\n                                      {item.shortcut.map((k) => (\n                                        <Kbd className=\"bg-background ring-1 ring-border\" key={k}>\n                                          {k}\n                                        </Kbd>\n                                      ))}\n                                    </KbdGroup>\n                                  )\n                                )}\n                              </div>\n                            );\n                          })}\n                        </div>\n                      ))}\n                    </motion.div>\n                  </AnimatePresence>\n                </div>\n              </motion.div>\n\n              <div className=\"flex items-center gap-3 border-t px-3 py-2 text-muted-foreground text-xs\">\n                <span className=\"flex items-center gap-1\">\n                  <Kbd>↑</Kbd>\n                  <Kbd>↓</Kbd>\n                  navigate\n                </span>\n                <span className=\"flex items-center gap-1\">\n                  <Kbd>\n                    <CornerDownLeftIcon />\n                  </Kbd>\n                  {current?.item.page ? \"open\" : \"run\"}\n                </span>\n                <span className=\"ms-auto flex items-center gap-1\">\n                  <Kbd>{page ? \"⌫\" : \"esc\"}</Kbd>\n                  {page ? \"back\" : \"close\"}\n                </span>\n              </div>\n            </motion.div>\n          </motion.div>\n        ) : (\n          <motion.button\n            className=\"flex h-10 w-full cursor-pointer items-center gap-2 border bg-popover px-3 text-muted-foreground text-sm shadow-xs/5 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n            key=\"trigger\"\n            layoutId={`${baseId}-shell`}\n            onClick={show}\n            ref={triggerRef}\n            style={{ borderRadius: radius * 1.2 }}\n            transition={spring}\n            type=\"button\"\n          >\n            <SearchIcon className=\"size-4\" />\n            <span className=\"flex-1 text-start\">{triggerLabel}</span>\n            <KbdGroup>\n              <Kbd>⌘</Kbd>\n              <Kbd>K</Kbd>\n            </KbdGroup>\n          </motion.button>\n        )}\n      </AnimatePresence>\n      <AnimatePresence>\n        {lastRun && !open && (\n          <motion.p\n            animate={{ opacity: 1, y: 0 }}\n            aria-live=\"polite\"\n            className=\"mt-3 flex items-center justify-center gap-1.5 text-muted-foreground text-xs\"\n            exit={{ opacity: 0, y: -4 }}\n            initial={{ opacity: 0, y: 6 }}\n          >\n            <CircleCheckIcon className=\"size-3.5 text-success\" />\n            {lastRun}\n          </motion.p>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"
    }
  ]
}