{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-launcher",
  "title": "App Launcher",
  "description": "A nine-dot app switcher: a grid button that drops a portalled tile grid of real links, with arrow-key grid navigation, groups, pinned and recent rows, and a search box once the suite outgrows a 3x3.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/app-launcher.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { ArrowUpRight, LayoutGrid, Search } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Entrance keyframes ship with the component: React 19 hoists <style href> into\n * <head> and dedupes by href, so several launchers on a page still emit one rule\n * set. One keyframe per side — the panel always slides *away* from its trigger.\n */\nconst KEYFRAMES = `@keyframes zg-app-launcher-in-bottom{from{opacity:0;transform:translateY(-4px) scale(0.98)}to{opacity:1;transform:none}}\n@keyframes zg-app-launcher-in-top{from{opacity:0;transform:translateY(4px) scale(0.98)}to{opacity:1;transform:none}}`\n\n/**\n * No scroll lock here, on purpose. This panel is *non-modal*: it hangs off a\n * toolbar button, the page behind it stays readable and interactive, and the\n * panel re-measures on every scroll (capture-phase listener below) so it keeps\n * tracking its trigger. Locking the body would freeze a page the user never\n * asked to leave and would drag in scrollbar-width compensation for nothing.\n * A launcher that *should* block the page is a dialog, not this component.\n */\n\nconst FOCUSABLE_SELECTOR =\n  'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\n/**\n * Actual tab stops inside the panel — the search box and the one tile holding\n * the roving tabIndex.\n *\n * The `tabIndex >= 0` filter is the whole point: every tile is an `<a href>`, so\n * a plain selector match would report nine tab stops in a 3×3 grid and the\n * \"am I on the last one\" test below would never be true.\n */\nfunction tabStopsIn(root: HTMLElement): HTMLElement[] {\n  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(\n    el => el.tabIndex >= 0 && (el.offsetParent !== null || el === document.activeElement),\n  )\n}\n\n/** Gap between the trigger and the panel. */\nconst GAP = 6\n/** Breathing room kept between the panel and the boundary it is clamped into. */\nconst EDGE_MARGIN = 8\n/** One tile column. The panel width is derived from it, never measured. */\nconst TILE_WIDTH = 96\n/** `gap-1` between tiles, in px — the layout maths has to agree with the class. */\nconst TILE_GAP = 4\n/** `p-2` around the tile area, in px. */\nconst PANEL_PADDING = 8\n/**\n * Preferred floor for the panel: when its own side of the trigger is short, the\n * panel keeps this height and is shifted back inside the boundary rather than\n * hanging out of it. It still yields to a boundary that cannot hold that much.\n */\nconst MIN_PANEL_HEIGHT = 176\n\nexport interface AppLauncherApp {\n  /** Stable and unique. Referenced by `pinnedIds` / `recentIds`. */\n  id: string\n  /** Tile label. Also the accessible name of the link. */\n  name: string\n  /** Real destination. Same-origin path or absolute URL — never \"#\". */\n  href: string\n  /** Rendered as-is inside the icon square (a lucide glyph, an emoji, an inline svg). */\n  icon?: React.ReactNode\n  /** Square logo URL. Used only when `icon` is absent; falls back to initials if it fails to load. */\n  iconUrl?: string\n  /** Second line under the name, clamped to two lines. Also matched by the search box. */\n  description?: string\n  /** Short pill in the tile corner — \"New\", \"Beta\", \"3\". */\n  badge?: string\n  /** Opens in a new tab: `target=\"_blank\"` + `rel=\"noopener noreferrer\"` + a visible arrow marker. */\n  external?: boolean\n  /** Not navigable, but still focusable and announced — see the disabled note in the docs. */\n  disabled?: boolean\n  /** Matches an `AppLauncherGroup.id`. Unknown / missing ids land in the trailing section. */\n  groupId?: string\n}\n\nexport interface AppLauncherGroup {\n  /** Matched against `AppLauncherApp.groupId`. */\n  id: string\n  /** Section heading above the sub-grid. */\n  label: string\n}\n\nexport interface AppLauncherProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"type\" | \"children\" | \"onSelect\"> {\n  /** Every app in the switcher, in display order. */\n  apps: AppLauncherApp[]\n  /** Optional sections. Apps are matched by `groupId`; leftovers go to `otherLabel`. */\n  groups?: AppLauncherGroup[]\n  /** ids surfaced in a \"Pinned\" section at the top. Unknown ids are ignored. */\n  pinnedIds?: string[]\n  /** ids surfaced in a \"Recent\" section, in the order given. Unknown ids are ignored. */\n  recentIds?: string[]\n  /** Preferred column count. Clamped to 1–8 and reduced when the boundary is too narrow. Default 3. */\n  columns?: number\n  /** The search box appears once the app count is *greater* than this. Clamped to >= 0. Default 9 (a full 3×3). */\n  searchThreshold?: number\n  searchPlaceholder?: string\n  emptyText?: string\n  /** Accessible name of the trigger and of the panel. Default \"Apps\". */\n  label?: string\n  /** Trigger edge the panel lines up with before it is shift-clamped. Default \"end\". */\n  align?: \"start\" | \"end\"\n  /** Controlled open state; omit it to let the component own it. */\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n  /** Fires when an enabled app is activated, right before the panel closes. Navigation is the link's own job. */\n  onLaunch?: (app: AppLauncherApp) => void\n  pinnedLabel?: string\n  recentLabel?: string\n  /** Heading for apps that match no group, used only when another labelled section exists. */\n  otherLabel?: string\n  /** Merged onto the portalled panel; `className` goes to the trigger button. */\n  panelClassName?: string\n}\n\ntype PanelSide = \"top\" | \"bottom\"\n\ninterface Bounds {\n  top: number\n  right: number\n  bottom: number\n  left: number\n}\n\ninterface PanelLayout {\n  side: PanelSide\n  left: number\n  top: number\n  /** Effective column count for this boundary — the source of the row chunking. */\n  columns: number\n  width: number\n  maxHeight: number\n}\n\ninterface Cell {\n  app: AppLauncherApp\n  /** Unique across sections: the same app may appear pinned *and* in its group. */\n  key: string\n  /** Position in the flat, document-order cell list. Doubles as the DOM lookup key. */\n  index: number\n  row: number\n  col: number\n}\n\ninterface Section {\n  key: string\n  label: string | null\n  labelId: string\n  cells: Cell[]\n  rows: Cell[][]\n}\n\nconst ENTER_ANIMATION: Record<PanelSide, string> = {\n  bottom: \"[animation:zg-app-launcher-in-bottom_140ms_ease-out]\",\n  top: \"[animation:zg-app-launcher-in-top_140ms_ease-out]\",\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), Math.max(min, max))\n}\n\n/** Integer prop guard: `columns={0}` / `NaN` / `Infinity` would otherwise divide by zero or render nothing. */\nfunction clampInt(value: number | undefined, fallback: number, min: number, max: number) {\n  if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n  return clamp(Math.floor(value), min, max)\n}\n\n/** First grapheme of a word, so an emoji or a CJK name is never cut in half. */\nfunction firstChar(word: string) {\n  return Array.from(word)[0] ?? \"\"\n}\n\n/** \"Mail\" → \"M\"; \"Northwind Labs\" → \"NL\"; \"\" → \"?\". Deterministic, render-safe. */\nfunction initialsOf(name: string) {\n  const words = name.trim().split(/\\s+/).filter(Boolean)\n  if (words.length === 0) return \"?\"\n  if (words.length === 1) return firstChar(words[0]).toUpperCase()\n  return (firstChar(words[0]) + firstChar(words[words.length - 1])).toUpperCase()\n}\n\nfunction matchesQuery(app: AppLauncherApp, query: string) {\n  return `${app.name} ${app.description ?? \"\"} ${app.badge ?? \"\"}`.toLowerCase().includes(query)\n}\n\n/** Width the panel wants for `columns` tiles. Pure maths — the panel is never measured horizontally. */\nfunction panelWidthFor(columns: number) {\n  return columns * TILE_WIDTH + (columns - 1) * TILE_GAP + PANEL_PADDING * 2\n}\n\n/**\n * The box the panel has to stay inside, in viewport coordinates.\n *\n * Two ancestor kinds matter and they are treated differently — this is the whole\n * reason the panel is portalled to <body>:\n *\n * - `overflow: hidden | clip` ancestors (a rounded app bar, a docs preview stage,\n *   a card) would *clip* an in-flow panel: the tiles exist in the DOM, the pixels\n *   don't, and nothing is clickable. A portalled panel is not their descendant so\n *   they cannot clip it — and for the same reason they must NOT act as a boundary\n *   either, or a 12-app grid gets crushed into a 120px decorative header.\n * - `overflow: auto | scroll` ancestors are a real viewport for the trigger (a\n *   scrollable sidebar, a dialog body). A panel spilling out of one would float\n *   over unrelated content while its trigger scrolls away, so those DO clamp it —\n *   and, here, also decide how many columns fit.\n *\n * The window is always the outermost boundary.\n */\nfunction clipBounds(anchor: HTMLElement): Bounds {\n  const bounds: Bounds = { top: 0, left: 0, right: window.innerWidth, bottom: window.innerHeight }\n\n  let node: HTMLElement | null = anchor.parentElement\n  while (node && node !== document.body && node !== document.documentElement) {\n    const style = getComputedStyle(node)\n    if (/auto|scroll/.test(`${style.overflowX} ${style.overflowY}`)) {\n      const rect = node.getBoundingClientRect()\n      bounds.top = Math.max(bounds.top, rect.top)\n      bounds.left = Math.max(bounds.left, rect.left)\n      bounds.right = Math.min(bounds.right, rect.right)\n      bounds.bottom = Math.min(bounds.bottom, rect.bottom)\n    }\n    node = node.parentElement\n  }\n\n  return bounds\n}\n\n/**\n * One synchronous pass: columns → width → measure natural height → flip → cap →\n * align → shift.\n *\n * The column count is derived from the *boundary*, never from the panel's own\n * rendered width. That is what keeps the pass loop-free: the panel's width is a\n * function of the columns, so measuring the width to pick the columns would feed\n * the result straight back into its own input. A 390px phone gets 3 columns out\n * of a `columns={4}` request instead of a panel hanging off the screen.\n *\n * The natural *height* is still measured, with our own cap momentarily lifted —\n * a panel already clamped by a previous `maxHeight` always looks like it fits,\n * which is the classic hand-rolled-popover flip oscillation. Nothing is painted\n * in between (this runs inside one ResizeObserver / rAF callback), but dropping\n * the cap collapses the scroll area, so its offset is saved and put back.\n */\nfunction computeLayout(\n  anchor: HTMLElement,\n  panel: HTMLElement,\n  scroller: HTMLElement | null,\n  align: \"start\" | \"end\",\n  requestedColumns: number,\n): PanelLayout {\n  const bounds = clipBounds(anchor)\n  const area = {\n    top: bounds.top + EDGE_MARGIN,\n    left: bounds.left + EDGE_MARGIN,\n    right: bounds.right - EDGE_MARGIN,\n    bottom: bounds.bottom - EDGE_MARGIN,\n  }\n  const rect = anchor.getBoundingClientRect()\n\n  const available = area.right - area.left\n  const columns = clamp(\n    Math.floor((available - PANEL_PADDING * 2 + TILE_GAP) / (TILE_WIDTH + TILE_GAP)),\n    1,\n    requestedColumns,\n  )\n  // One column can still be wider than a pathologically narrow boundary; clamping\n  // keeps the panel inside it and lets the tile shrink rather than overflow.\n  const width = Math.min(panelWidthFor(columns), Math.max(TILE_GAP, available))\n\n  const previousMaxHeight = panel.style.maxHeight\n  const scrollTop = scroller ? scroller.scrollTop : 0\n  panel.style.maxHeight = \"none\"\n  const naturalHeight = panel.offsetHeight\n  panel.style.maxHeight = previousMaxHeight\n  if (scroller) scroller.scrollTop = scrollTop\n\n  const spaceBelow = area.bottom - rect.bottom - GAP\n  const spaceAbove = rect.top - area.top - GAP\n\n  // FLIP only when the other side is genuinely roomier, so a panel too tall for\n  // both sides stays below the trigger and scrolls instead of ping-ponging.\n  const side: PanelSide = naturalHeight > spaceBelow && spaceAbove > spaceBelow ? \"top\" : \"bottom\"\n\n  // The cap is allowed to keep MIN_PANEL_HEIGHT on a short side — the shift\n  // below pushes the panel back inside the boundary — but it may never exceed\n  // what the boundary can hold, or the shift would have nowhere to put it.\n  const areaHeight = Math.floor(area.bottom - area.top)\n  const maxHeight = clamp(\n    Math.floor(side === \"bottom\" ? spaceBelow : spaceAbove),\n    Math.min(MIN_PANEL_HEIGHT, areaHeight),\n    areaHeight,\n  )\n  const height = Math.min(naturalHeight, maxHeight)\n\n  const anchored = align === \"end\" ? rect.right - width : rect.left\n  // SHIFT on BOTH axes until the panel sits inside the boundary. The main axis\n  // needs it as much as the cross axis: the panel is `position: fixed`, so a\n  // trigger sitting a few pixels above the boundary's bottom edge would\n  // otherwise park a full-height panel mostly off-screen, where scrolling can\n  // never reach it and every tile is unclickable.\n  const left = clamp(anchored, area.left, area.right - width)\n  const preferred = side === \"bottom\" ? rect.bottom + GAP : rect.top - GAP - height\n  const top = clamp(preferred, area.top, area.bottom - height)\n\n  return { side, left: Math.round(left), top: Math.round(top), columns, width: Math.round(width), maxHeight }\n}\n\nfunction sameLayout(a: PanelLayout | null, b: PanelLayout): a is PanelLayout {\n  return (\n    a !== null &&\n    a.side === b.side &&\n    a.left === b.left &&\n    a.top === b.top &&\n    a.columns === b.columns &&\n    a.width === b.width &&\n    a.maxHeight === b.maxHeight\n  )\n}\n\nfunction subscribeNoop() {\n  // \"am I on the client\" has no external event to subscribe to.\n  return () => {}\n}\n\n/** SSR-safe portal gate: false on the server and on the hydrating frame. */\nfunction useIsClient() {\n  return React.useSyncExternalStore(\n    subscribeNoop,\n    () => true,\n    () => false,\n  )\n}\n\n/**\n * Icon square: a caller-supplied node, else a remote logo, else initials.\n *\n * `aria-hidden` because the tile prints the app name right underneath — the icon\n * would only duplicate it.\n */\nfunction AppIcon({ app }: { app: AppLauncherApp }) {\n  const [failedSrc, setFailedSrc] = React.useState<string | null>(null)\n  const src = app.iconUrl && app.iconUrl !== failedSrc ? app.iconUrl : null\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-primary/10 text-sm font-semibold text-primary [&_svg]:size-5\"\n    >\n      {app.icon ? (\n        app.icon\n      ) : src ? (\n        // eslint-disable-next-line @next/next/no-img-element -- registry component stays framework-agnostic, no next/image binding\n        <img\n          alt=\"\"\n          className=\"size-full object-cover\"\n          onError={() => setFailedSrc(src)}\n          ref={node => {\n            // On a prerendered page a cached (or data-URI) image can finish\n            // decoding *before* hydration attaches onError, so the event never\n            // arrives and a broken icon sits there forever. `complete` with a\n            // zero natural width is the synchronous way to catch the ones that\n            // already settled. The ref runs after every commit, so a later\n            // failure of an already-mounted <img> is caught too.\n            if (node && node.complete && node.naturalWidth === 0) setFailedSrc(src)\n          }}\n          src={src}\n        />\n      ) : (\n        initialsOf(app.name)\n      )}\n    </span>\n  )\n}\n\n/**\n * One tile: a real link, so middle-click, \"copy link address\" and the browser's\n * own status bar all keep working.\n *\n * `data-cell` is the index into the flat cell list — it is how the grid's\n * keyboard handler finds a node to focus without keeping a ref per tile.\n */\nfunction AppTile({\n  app,\n  index,\n  onActivate,\n  tabbable,\n}: {\n  app: AppLauncherApp\n  index: number\n  onActivate: (app: AppLauncherApp) => void\n  tabbable: boolean\n}) {\n  return (\n    <a\n      aria-disabled={app.disabled ? true : undefined}\n      className={cn(\n        \"relative flex h-full flex-col items-center gap-1.5 rounded-lg p-2 text-center outline-none transition-colors\",\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n        app.disabled ? \"cursor-not-allowed opacity-50\" : \"cursor-pointer hover:bg-accent hover:text-accent-foreground\",\n        \"motion-reduce:transition-none\",\n      )}\n      data-cell={index}\n      // A disabled tile keeps its slot and stays focusable, both by Tab (when it\n      // holds the roving tabIndex) and by the arrows: skipping it would make an\n      // app silently vanish for keyboard and screen-reader users, who then never\n      // learn it exists but is unavailable to them. Dropping `href` is what\n      // makes it inert — no navigation, no context menu offering a dead URL.\n      href={app.disabled ? undefined : app.href}\n      onClick={event => {\n        if (app.disabled) {\n          event.preventDefault()\n          return\n        }\n        onActivate(app)\n      }}\n      rel={app.external ? \"noopener noreferrer\" : undefined}\n      // An <a> without href has no implicit role, which would leave the gridcell\n      // owning an anonymous box; role=\"link\" + aria-disabled keeps it announced.\n      role={app.disabled ? \"link\" : undefined}\n      tabIndex={tabbable ? 0 : -1}\n      target={app.external ? \"_blank\" : undefined}\n    >\n      <AppIcon app={app} />\n\n      <span className=\"flex w-full min-w-0 items-center justify-center gap-0.5\">\n        <span className=\"min-w-0 truncate text-xs font-medium\">{app.name}</span>\n        {app.external ? <ArrowUpRight aria-hidden=\"true\" className=\"size-3 shrink-0 text-muted-foreground\" /> : null}\n      </span>\n      {app.external ? <span className=\"sr-only\">(opens in a new tab)</span> : null}\n\n      {app.description ? (\n        <span className=\"line-clamp-2 w-full text-[11px] leading-tight text-muted-foreground\">{app.description}</span>\n      ) : null}\n\n      {app.badge ? (\n        <span className=\"absolute right-1 top-1 max-w-[70%] truncate rounded-full border bg-background px-1.5 text-[10px] font-medium leading-4 text-muted-foreground\">\n          {app.badge}\n        </span>\n      ) : null}\n    </a>\n  )\n}\n\n/**\n * A Google-style nine-dot app switcher: a grid button that drops a panel of\n * app tiles for jumping between the products of one suite.\n *\n * The panel is portalled to <body> with `position: fixed`, so no `overflow:\n * hidden` app bar or card can clip it, and is then flipped / shifted / capped\n * against the nearest *scrollable* boundary — which also decides how many\n * columns fit (see `clipBounds` and `computeLayout`).\n *\n * Tiles are real links inside an ARIA grid: one tab stop, arrows move DOM focus\n * (right/left inside a row, up/down across rows, Home/End to the first/last app).\n *\n * The forwarded ref points at the trigger button.\n */\nexport const AppLauncher = React.forwardRef<HTMLButtonElement, AppLauncherProps>(\n  (\n    {\n      apps,\n      groups,\n      pinnedIds,\n      recentIds,\n      columns = 3,\n      searchThreshold = 9,\n      searchPlaceholder = \"Search apps…\",\n      emptyText = \"No apps found.\",\n      label = \"Apps\",\n      align = \"end\",\n      open: openProp,\n      onOpenChange,\n      onLaunch,\n      pinnedLabel = \"Pinned\",\n      recentLabel = \"Recent\",\n      otherLabel = \"All apps\",\n      className,\n      panelClassName,\n      onClick,\n      onKeyDown,\n      ...rest\n    },\n    forwardedRef,\n  ) => {\n    const baseId = React.useId()\n    const panelId = `${baseId}-panel`\n    const isClient = useIsClient()\n\n    const triggerRef = React.useRef<HTMLButtonElement>(null)\n    React.useImperativeHandle(forwardedRef, () => triggerRef.current as HTMLButtonElement)\n    const panelRef = React.useRef<HTMLDivElement>(null)\n    const scrollerRef = React.useRef<HTMLDivElement>(null)\n    const inputRef = React.useRef<HTMLInputElement>(null)\n    // Outside interactions must not yank focus back to the trigger — the user is\n    // already somewhere else. Esc, launching and trigger toggles do restore it.\n    const restoreFocusRef = React.useRef(true)\n\n    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)\n    const controlled = openProp !== undefined\n    const open = controlled ? openProp : uncontrolledOpen\n\n    // Latest-ref: consumers pass inline arrows, so keeping the callbacks out of\n    // every dependency array is what stops the effects below from detaching and\n    // re-attaching on every parent render. Ref writes belong in effects.\n    const onOpenChangeRef = React.useRef(onOpenChange)\n    React.useEffect(() => {\n      onOpenChangeRef.current = onOpenChange\n    })\n\n    const setOpen = React.useCallback(\n      (next: boolean) => {\n        if (!controlled) setUncontrolledOpen(next)\n        onOpenChangeRef.current?.(next)\n      },\n      [controlled],\n    )\n\n    const [query, setQuery] = React.useState(\"\")\n    const [layout, setLayout] = React.useState<PanelLayout | null>(null)\n    const [storedActiveKey, setStoredActiveKey] = React.useState<string | null>(null)\n\n    // Render-phase adjust-state (never an effect): a closed panel forgets its\n    // placement so the next open cannot paint one frame at stale coordinates,\n    // and reopens with a clean query and the first tile armed.\n    const [prevOpen, setPrevOpen] = React.useState(open)\n    if (open !== prevOpen) {\n      setPrevOpen(open)\n      if (open) {\n        setQuery(\"\")\n        setStoredActiveKey(null)\n      } else {\n        setLayout(null)\n      }\n    }\n\n    const requestedColumns = clampInt(columns, 3, 1, 8)\n    const effectiveColumns = layout?.columns ?? requestedColumns\n    const threshold = clampInt(searchThreshold, 9, 0, Number.MAX_SAFE_INTEGER)\n    // Clamped: a negative threshold would be honoured literally, and\n    // `searchThreshold={0}` must mean \"always show\", not \"never\".\n    const showSearch = apps.length > threshold\n\n    const normalizedQuery = query.trim().toLowerCase()\n\n    /* ------------------------------------------------------------------ *\n     * Sections → rows → cells. A launcher is tens of apps, not thousands,\n     * so this is recomputed per render instead of pretending `apps` has a\n     * stable identity.\n     * ------------------------------------------------------------------ */\n    const visible = normalizedQuery ? apps.filter(app => matchesQuery(app, normalizedQuery)) : apps\n    const byId = new Map(visible.map(app => [app.id, app]))\n    const pick = (ids: string[] | undefined) => {\n      if (!ids || ids.length === 0) return []\n      const seen = new Set<string>()\n      const picked: AppLauncherApp[] = []\n      for (const id of ids) {\n        const app = byId.get(id)\n        // Unknown ids are dropped, repeats are dropped: both would otherwise\n        // produce a duplicate DOM key inside the same section.\n        if (!app || seen.has(id)) continue\n        seen.add(id)\n        picked.push(app)\n      }\n      return picked\n    }\n\n    const pinned = pick(pinnedIds)\n    const recent = pick(recentIds)\n    const groupList = groups ?? []\n    const grouped = groupList.map(group => ({\n      group,\n      apps: visible.filter(app => app.groupId === group.id),\n    }))\n    const knownGroupIds = new Set(groupList.map(group => group.id))\n    const leftovers = visible.filter(app => !app.groupId || !knownGroupIds.has(app.groupId))\n    const hasLabelledSection = pinned.length > 0 || recent.length > 0 || grouped.some(entry => entry.apps.length > 0)\n\n    let cellIndex = 0\n    let rowIndex = 0\n    let sectionIndex = 0\n    const buildSection = (key: string, sectionLabel: string | null, sectionApps: AppLauncherApp[]): Section => {\n      const cells = sectionApps.map(app => ({ app, key: `${key}:${app.id}`, index: cellIndex++, row: 0, col: 0 }))\n      const rows: Cell[][] = []\n      for (let start = 0; start < cells.length; start += effectiveColumns) {\n        const row = cells.slice(start, start + effectiveColumns)\n        row.forEach((cell, col) => {\n          cell.row = rowIndex\n          cell.col = col\n        })\n        rows.push(row)\n        rowIndex += 1\n      }\n      // Positional label id: a group id containing whitespace would otherwise\n      // produce an IDREF that aria-labelledby can never resolve.\n      return { key, label: sectionLabel, labelId: `${baseId}-section-${sectionIndex++}`, cells, rows }\n    }\n\n    const sections: Section[] = []\n    if (pinned.length > 0) sections.push(buildSection(\"pinned\", pinnedLabel, pinned))\n    if (recent.length > 0) sections.push(buildSection(\"recent\", recentLabel, recent))\n    for (const entry of grouped) {\n      if (entry.apps.length > 0) sections.push(buildSection(`group-${entry.group.id}`, entry.group.label, entry.apps))\n    }\n    if (leftovers.length > 0) {\n      sections.push(buildSection(\"other\", hasLabelledSection ? otherLabel : null, leftovers))\n    }\n\n    const cells = sections.flatMap(section => section.cells)\n    const rows = sections.flatMap(section => section.rows)\n    // Derived, not stored: when filtering drops the armed tile the fallback\n    // happens during render, so there is no setState-in-effect and no frame with\n    // a roving tabIndex pointing at a cell that left the DOM.\n    const activeCell = cells.find(cell => cell.key === storedActiveKey) ?? cells[0] ?? null\n\n    const close = React.useCallback(\n      (restoreFocus: boolean) => {\n        restoreFocusRef.current = restoreFocus\n        setOpen(false)\n      },\n      [setOpen],\n    )\n\n    /* ---------------------------------------------------------------- *\n     * Positioning\n     * ---------------------------------------------------------------- */\n    React.useEffect(() => {\n      if (!open) return\n\n      let frame = 0\n      const apply = () => {\n        const anchor = triggerRef.current\n        const panel = panelRef.current\n        if (!anchor || !panel) return\n        const next = computeLayout(anchor, panel, scrollerRef.current, align, requestedColumns)\n        // Identity guard: the ResizeObserver re-fires once our own width / cap\n        // land on the panel. Bailing on an unchanged result turns that into a\n        // single no-op callback instead of a loop.\n        setLayout(previous => (sameLayout(previous, next) ? previous : next))\n      }\n      const schedule = (event: Event) => {\n        // Scrolling *inside* the tile area changes nothing about where the panel\n        // goes, and remeasuring would pointlessly touch its scroll offset.\n        const target = event.target\n        if (target instanceof Node && panelRef.current?.contains(target)) return\n        if (frame) return\n        frame = requestAnimationFrame(() => {\n          frame = 0\n          apply()\n        })\n      }\n\n      // ResizeObserver fires once immediately after observe(), after layout and\n      // before paint — that first callback IS the initial measurement, so the\n      // panel is never painted at the wrong spot and nothing calls setState\n      // synchronously in this effect body.\n      let observer: ResizeObserver | null = null\n      if (typeof ResizeObserver !== \"undefined\") {\n        observer = new ResizeObserver(apply)\n        if (panelRef.current) observer.observe(panelRef.current)\n        if (triggerRef.current) observer.observe(triggerRef.current)\n      } else {\n        frame = requestAnimationFrame(() => {\n          frame = 0\n          apply()\n        })\n      }\n\n      // capture: true — `scroll` does not bubble, but the capture phase still\n      // passes through window for scrolls fired on any nested container, so the\n      // panel keeps tracking a trigger inside a scrollable sidebar.\n      window.addEventListener(\"scroll\", schedule, { capture: true, passive: true })\n      window.addEventListener(\"resize\", schedule)\n\n      return () => {\n        observer?.disconnect()\n        window.removeEventListener(\"scroll\", schedule, { capture: true })\n        window.removeEventListener(\"resize\", schedule)\n        if (frame) cancelAnimationFrame(frame)\n      }\n      // `rows.length` re-arms the measurement when filtering changes the natural\n      // height while the clamped box stays identical (the observer alone would\n      // never fire in that case).\n    }, [open, align, requestedColumns, rows.length, sections.length])\n\n    /* ---------------------------------------------------------------- *\n     * Focus\n     * ---------------------------------------------------------------- */\n    // Read at call time, never captured when the effect runs: the trigger node\n    // can be replaced between open and close. isConnected because launching an\n    // app routinely unmounts it, and focusing a detached node silently drops\n    // focus onto <body> instead of throwing.\n    const restoreTriggerFocus = React.useCallback(() => {\n      const back = triggerRef.current\n      if (back?.isConnected) back.focus({ preventScroll: true })\n    }, [])\n\n    React.useEffect(() => {\n      if (!open) return\n      restoreFocusRef.current = true\n\n      // preventScroll: the first frame has no coordinates yet, so focusing\n      // without it would scroll the page toward a panel parked at 0,0.\n      const target =\n        inputRef.current ?? scrollerRef.current?.querySelector<HTMLElement>(\"[data-cell]\") ?? panelRef.current\n      target?.focus({ preventScroll: true })\n\n      return () => {\n        if (restoreFocusRef.current) restoreTriggerFocus()\n      }\n    }, [open, restoreTriggerFocus])\n\n    /* ---------------------------------------------------------------- *\n     * Dismissal — non-modal: no focus trap, no scroll lock.\n     * ---------------------------------------------------------------- */\n    React.useEffect(() => {\n      if (!open) return\n\n      const isInside = (target: EventTarget | null) =>\n        target instanceof Node &&\n        (panelRef.current?.contains(target) === true || triggerRef.current?.contains(target) === true)\n\n      // pointerdown, not click: the panel must be gone before the press turns\n      // into a click on whatever sits underneath.\n      const handlePointerDown = (event: PointerEvent) => {\n        if (isInside(event.target)) return\n        close(false)\n      }\n      // Tab out of a portalled panel lands somewhere unrelated in DOM order.\n      // A non-modal panel reads that as \"the user left\" and closes, instead of\n      // pretending to trap focus it never claimed.\n      const handleFocusIn = (event: FocusEvent) => {\n        if (isInside(event.target)) return\n        close(false)\n      }\n\n      document.addEventListener(\"pointerdown\", handlePointerDown)\n      document.addEventListener(\"focusin\", handleFocusIn)\n      return () => {\n        document.removeEventListener(\"pointerdown\", handlePointerDown)\n        document.removeEventListener(\"focusin\", handleFocusIn)\n      }\n    }, [open, close])\n\n    /* ---------------------------------------------------------------- *\n     * Grid navigation\n     * ---------------------------------------------------------------- */\n    const cellElement = (index: number) =>\n      scrollerRef.current?.querySelector<HTMLElement>(`[data-cell=\"${index}\"]`) ?? null\n\n    /**\n     * Focus moves synchronously here rather than through an effect: arrowing\n     * onto the cell that is already stored is a no-op setState, React skips the\n     * render, and an effect-driven `focus()` would never run — the focus ring\n     * would simply stop moving. State only follows the DOM for the roving\n     * tabIndex.\n     */\n    const focusCell = (cell: Cell) => {\n      setStoredActiveKey(cell.key)\n      const el = cellElement(cell.index)\n      if (!el) return\n      el.focus({ preventScroll: true })\n      const scroller = scrollerRef.current\n      // Manual scrolling rather than scrollIntoView: the latter also walks up\n      // and scrolls ancestors, which would move the page behind this non-modal\n      // panel. The scroller is `relative`, so it is the tile's offsetParent.\n      if (!scroller) return\n      const top = el.offsetTop\n      const height = el.offsetHeight\n      const bottom = top + height\n      if (height > scroller.clientHeight) {\n        // Taller than the strip the panel was squeezed into (a trigger sitting\n        // at the very bottom edge of a short scrollable boundary): neither edge\n        // can frame it, and aligning the bottom would push the tile's middle —\n        // the part the pointer lands on — back out of view. Centre it instead.\n        scroller.scrollTop = Math.max(0, top + (height - scroller.clientHeight) / 2)\n      } else if (top < scroller.scrollTop) scroller.scrollTop = Math.max(0, top - PANEL_PADDING)\n      else if (bottom > scroller.scrollTop + scroller.clientHeight) {\n        scroller.scrollTop = bottom - scroller.clientHeight + PANEL_PADDING\n      }\n    }\n\n    const cellInRow = (row: number, col: number): Cell | null => {\n      const target = rows[row]\n      if (!target || target.length === 0) return null\n      return target[Math.min(col, target.length - 1)]\n    }\n\n    const handleGridKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n      const origin = event.target instanceof Element ? event.target.closest<HTMLElement>(\"[data-cell]\") : null\n      if (!origin) return\n      const current = cells[Number(origin.dataset.cell)]\n      if (!current) return\n\n      let next: Cell | null = null\n      switch (event.key) {\n        case \"ArrowRight\":\n          next = cellInRow(current.row, current.col + 1)\n          break\n        case \"ArrowLeft\":\n          next = current.col > 0 ? cellInRow(current.row, current.col - 1) : null\n          break\n        case \"ArrowDown\":\n          next = cellInRow(current.row + 1, current.col)\n          break\n        case \"ArrowUp\":\n          next = current.row > 0 ? cellInRow(current.row - 1, current.col) : null\n          break\n        case \"Home\":\n          next = cells[0] ?? null\n          break\n        case \"End\":\n          next = cells[cells.length - 1] ?? null\n          break\n        case \" \":\n          // Space would otherwise scroll the page behind this non-modal panel.\n          // Enter needs no help: these tiles are real links.\n          event.preventDefault()\n          origin.click()\n          return\n        default:\n          return\n      }\n\n      // The arrows never wrap and never leave the grid: a launcher is a plane,\n      // and silently teleporting from the last tile back to the first one is how\n      // people lose their place in it.\n      event.preventDefault()\n      if (next && next.index !== current.index) focusCell(next)\n    }\n\n    // Pointer or Tab focus must adopt the roving tabIndex too, or the next arrow\n    // press would jump back to wherever the state last pointed.\n    const handleGridFocus = (event: React.FocusEvent<HTMLDivElement>) => {\n      const cellEl = event.target instanceof Element ? event.target.closest<HTMLElement>(\"[data-cell]\") : null\n      if (!cellEl) return\n      const cell = cells[Number(cellEl.dataset.cell)]\n      if (cell) setStoredActiveKey(cell.key)\n    }\n\n    const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n      if (event.key === \"ArrowDown\") {\n        event.preventDefault()\n        if (activeCell) focusCell(activeCell)\n        return\n      }\n      if (event.key === \"Enter\") {\n        // Launch the first match, the way a search field is expected to behave.\n        // It clicks the real anchor, so the browser does the navigating and\n        // `external` targets keep their user activation.\n        const first = cells.find(cell => !cell.app.disabled)\n        if (!first) return\n        event.preventDefault()\n        cellElement(first.index)?.click()\n      }\n    }\n\n    const handlePanelKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault()\n        // stopPropagation so a launcher inside another overlay closes the\n        // innermost surface only.\n        event.stopPropagation()\n        close(true)\n        return\n      }\n      if (event.key !== \"Tab\") return\n\n      // Tab discipline for a portalled, non-modal panel. The panel sits at the\n      // end of <body>, so Tab off its last stop would walk out of the page\n      // entirely (measured: focus lands nowhere and the panel stays open behind\n      // it) and Shift+Tab off the first stop would land in unrelated content.\n      // Stepping off either edge closes and returns focus to the trigger; one\n      // more Tab then continues through the toolbar in the expected order.\n      // Trapping instead would be a lie: this panel never claims aria-modal.\n      const panel = panelRef.current\n      if (!panel) return\n      const nodes = tabStopsIn(panel)\n      if (nodes.length === 0) return\n      const active = document.activeElement\n      const atEdge = event.shiftKey ? active === nodes[0] : active === nodes[nodes.length - 1]\n      if (!atEdge) return\n      event.preventDefault()\n      close(true)\n    }\n\n    const launch = React.useCallback(\n      (app: AppLauncherApp) => {\n        onLaunch?.(app)\n        // The panel closes and the link still navigates: the anchor's default\n        // action was never cancelled, and the browser has already committed to\n        // it by the time React unmounts the portal.\n        close(true)\n      },\n      [close, onLaunch],\n    )\n\n    const panelWidth = layout?.width ?? panelWidthFor(requestedColumns)\n\n    return (\n      <>\n        <button\n          aria-controls={open ? panelId : undefined}\n          aria-expanded={open}\n          aria-haspopup=\"dialog\"\n          aria-label={label}\n          className={cn(\n            \"inline-flex size-9 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors\",\n            \"hover:bg-accent hover:text-accent-foreground\",\n            \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n            open && \"bg-accent text-accent-foreground\",\n            \"motion-reduce:transition-none\",\n            className,\n          )}\n          data-state={open ? \"open\" : \"closed\"}\n          onClick={event => {\n            onClick?.(event)\n            if (event.defaultPrevented) return\n            restoreFocusRef.current = true\n            setOpen(!open)\n          }}\n          onKeyDown={event => {\n            onKeyDown?.(event)\n            if (event.defaultPrevented) return\n            if (event.key === \"ArrowDown\") {\n              event.preventDefault()\n              restoreFocusRef.current = true\n              setOpen(true)\n            } else if (open && event.key === \"Escape\") {\n              // Reachable when a pointer press moved focus back to the trigger\n              // while the panel stayed open.\n              event.preventDefault()\n              close(true)\n            }\n          }}\n          ref={triggerRef}\n          type=\"button\"\n          {...rest}\n        >\n          <LayoutGrid aria-hidden=\"true\" className=\"size-5\" />\n        </button>\n\n        {isClient &&\n          open &&\n          createPortal(\n            <>\n              <style href=\"zyeon-app-launcher\" precedence=\"medium\">\n                {KEYFRAMES}\n              </style>\n\n              <div\n                aria-label={label}\n                className={cn(\n                  \"fixed z-50 flex flex-col overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md outline-none\",\n                  // No coordinates on the first frame: `opacity-0` hides it while\n                  // keeping it measurable *and* focusable — `visibility: hidden`\n                  // would silently drop the focus() above and take Escape, the\n                  // tiles and every other keyboard path down with it.\n                  layout ? ENTER_ANIMATION[layout.side] : \"opacity-0\",\n                  \"motion-reduce:[animation:none]\",\n                  panelClassName,\n                )}\n                data-side={layout?.side ?? \"bottom\"}\n                id={panelId}\n                onKeyDown={handlePanelKeyDown}\n                ref={panelRef}\n                role=\"dialog\"\n                style={{\n                  left: layout?.left ?? 0,\n                  top: layout?.top ?? 0,\n                  width: panelWidth,\n                  maxHeight: layout?.maxHeight,\n                }}\n                tabIndex={-1}\n              >\n                {showSearch ? (\n                  <div className=\"flex shrink-0 items-center gap-2 border-b px-3\">\n                    <Search aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n                    <input\n                      aria-label={searchPlaceholder}\n                      autoComplete=\"off\"\n                      className=\"h-10 w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n                      onChange={event => setQuery(event.target.value)}\n                      onKeyDown={handleSearchKeyDown}\n                      placeholder={searchPlaceholder}\n                      ref={inputRef}\n                      spellCheck=\"false\"\n                      type=\"text\"\n                      value={query}\n                    />\n                  </div>\n                ) : null}\n\n                <div\n                  // `relative` makes this the offsetParent of every tile, which\n                  // is what `focusCell` measures against.\n                  className=\"relative min-h-0 flex-1 overflow-y-auto overscroll-contain p-2\"\n                  onFocus={handleGridFocus}\n                  onKeyDown={handleGridKeyDown}\n                  ref={scrollerRef}\n                >\n                  {sections.length === 0 ? (\n                    <p className=\"px-2 py-8 text-center text-sm text-muted-foreground\" role=\"status\">\n                      {emptyText}\n                    </p>\n                  ) : (\n                    sections.map(section => (\n                      <div className=\"mb-3 last:mb-0\" key={section.key}>\n                        {section.label ? (\n                          <div\n                            className=\"px-1 pb-1.5 text-xs font-medium text-muted-foreground\"\n                            id={section.labelId}\n                          >\n                            {section.label}\n                          </div>\n                        ) : null}\n\n                        {/* The heading sits *outside* the grid and is referenced\n                            by id, so grid → row → gridcell is never interrupted\n                            by a node the grid is not allowed to own. */}\n                        <div\n                          aria-label={section.label ? undefined : label}\n                          aria-labelledby={section.label ? section.labelId : undefined}\n                          className=\"flex flex-col gap-1\"\n                          role=\"grid\"\n                        >\n                          {section.rows.map(row => (\n                            <div className=\"flex gap-1\" key={`row-${row[0].index}`} role=\"row\">\n                              {row.map(cell => (\n                                <div className=\"min-w-0 flex-1 basis-0\" key={cell.key} role=\"gridcell\">\n                                  <AppTile\n                                    app={cell.app}\n                                    index={cell.index}\n                                    onActivate={launch}\n                                    tabbable={cell.key === activeCell?.key}\n                                  />\n                                </div>\n                              ))}\n\n                              {/* Empty cells keep every row the same length, so\n                                  the columns line up and the grid keeps a square\n                                  shape in the accessibility tree. */}\n                              {Array.from({ length: effectiveColumns - row.length }, (_, filler) => (\n                                <div\n                                  className=\"min-w-0 flex-1 basis-0\"\n                                  key={`filler-${row[0].index}-${filler}`}\n                                  role=\"gridcell\"\n                                />\n                              ))}\n                            </div>\n                          ))}\n                        </div>\n                      </div>\n                    ))\n                  )}\n                </div>\n              </div>\n            </>,\n            document.body,\n          )}\n      </>\n    )\n  },\n)\n\nAppLauncher.displayName = \"AppLauncher\"\n\nexport default AppLauncher\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}