{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-preset-browser",
  "type": "registry:ui",
  "title": "Preset Browser",
  "description": "The unified preset library views for a host-built AudioModuleFacePresetLibrary (factory, every sampler bank, user and community presets). usePresetLibrary is the one headless controller: search, a groups-column scope (All, Favorites, User, host groups), AND tag filters and favorites, with filtering, tag counts and previous/next over the CURRENT filtered list taken from the audio-contracts helpers; it persists nothing but an optional per-viewer scope. PresetLibrarySelector drives the compact PresetSelector from the library with a modified marker, Save and Browse (offered past 30 entries or when the host asks). PresetBrowser is an overlay sheet INSIDE the face (nearest [data-preset-overlay-container] or [data-module-face-surface]): groups column with counts, a windowed Name / group / secondary / tags list with favorite hearts, the reused TagBrowser as the tags column, and a Save / Delete / previous / next / close toolbar; arrows move, Enter selects and closes, Space selects, F favorites, / searches, Escape clears then closes; below 640 px the side columns become drawers with removable filter chips. PresetLibraryNavigator is the library as a sidebar beside the face that grows with its width: drilled down (a groups view as a list with thumbnails or picture cards from group.artwork, then the open group's presets) while narrow, groups beside presets once wide (half and half, with a draggable divider), and the tags as a third column when wider still and the face allows it; a whole-library search over names and tag labels (a magnifier button until opened, / to focus), a header slot for the face's own selector and an Expand/Collapse button; opening a group lands on its first preset (or keeps the active one). PresetSaveDialog names the sound, picks up to 8 vocabulary tags, offers Overwrite for the viewer's own preset, and shows the host's pending/error/success answer inline. Every action is a request the host fulfils.",
  "dependencies": [
    "@base-ui/react",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-preset-selector.json",
    "https://pluginui.com/r/core-resize-handle.json",
    "https://pluginui.com/r/core-tag-browser.json"
  ],
  "files": [
    {
      "path": "components/ui/preset-browser.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/preset-browser\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * PresetBrowser — the full preset library as an overlay sheet over the face,\n * shaped like a hardware/plug-in preset browser (Elastic OSC style).\n *\n *  - Left: the groups column — All, Favorites, User (when present), then the\n *    host's groups (banks, Factory, My presets, authors) with counts that\n *    follow the current search and tags.\n *  - Centre: a search field and an efficient list (windowed past 120 rows)\n *    with columns Name / group (`groupLabel`) / secondary (`secondaryLabel`,\n *    hidden when no entry has one) / tags, and a favorite heart per row.\n *  - Right: the reused `TagBrowser` over the library's fixed vocabulary, with\n *    narrowing counts; zero-count tags cannot be picked.\n *  - Toolbar: the active preset (with the modified marker), previous/next\n *    over the filtered list, Save, Delete (only for the active entry when it\n *    is not read-only and the host can delete), Close.\n *\n * Keyboard: arrows/PageUp/PageDown move the highlighted row (Home/End in the\n * list), Enter selects and closes, Space selects and stays, F toggles the\n * highlighted row's favorite, Escape clears the search and then closes, `/`\n * focuses the search. A click selects and keeps the browser open; a double\n * click selects and closes.\n *\n * Below `narrowWidth` (640 px of its own width) the side columns collapse\n * into Groups and Tags drawers and the active filters show as removable chips.\n *\n * Every action is a request: `select`, `toggleFavorite`, `delete` and `save`\n * go to the host's `AudioModuleFacePresetLibrary`; the browser persists\n * nothing. It renders inside the nearest face container (see\n * `preset-overlay-container`), on Base UI's Dialog for focus trapping and\n * focus return.\n */\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\"\nimport { Dialog } from \"@base-ui/react/dialog\"\nimport type {\n  AudioModuleFacePresetLibrary,\n  PresetLibraryEntry,\n} from \"maruaudio/ui/shared/maru/contracts/preset-library\"\nimport { useElementSize } from \"maruaudio/ui/shared/use-element-size\"\nimport { TagBrowser } from \"@/components/ui/tag-browser\"\nimport type { TagSelection } from \"maruaudio/ui/shared/tag-browser-model\"\nimport {\n  PRESET_LIBRARY_TAG_CATEGORY,\n  presetEntryTagLabels,\n  type PresetLibraryScopeRow,\n} from \"maruaudio/ui/shared/preset-library-model\"\nimport {\n  usePresetLibrary,\n  type PresetLibraryController,\n} from \"maruaudio/ui/shared/use-preset-library\"\nimport { usePresetOverlayTarget } from \"maruaudio/ui/shared/preset-overlay-container\"\nimport { PresetSaveDialog } from \"@/components/ui/preset-save-dialog\"\nimport {\n  PRESET_BROWSER_STYLES,\n  type PresetBrowserVariant,\n} from \"maruaudio/ui/shared/preset-browser-styles\"\n\nexport type PresetBrowserLayout = \"wide\" | \"narrow\"\n\nexport interface PresetBrowserProps {\n  readonly library: AudioModuleFacePresetLibrary\n  /**\n   * Shared controller from `usePresetLibrary`, so the selector and the\n   * browser filter and step over the same list. Omitted, the browser keeps\n   * its own.\n   */\n  readonly controller?: PresetLibraryController\n  /** Controlled open state. Defaults to the controller's `browserOpen`. */\n  readonly open?: boolean\n  readonly onOpenChange?: (open: boolean) => void\n  /** Render into this element instead of the nearest face container. */\n  readonly container?: HTMLElement | null\n  /** `auto` (default) collapses the side columns below `narrowWidth`. */\n  readonly layout?: \"auto\" | PresetBrowserLayout\n  /** Own width below which `auto` is narrow. Defaults to 640. */\n  readonly narrowWidth?: number\n  readonly variant?: PresetBrowserVariant\n  /** Toolbar heading. Defaults to \"Presets\". */\n  readonly title?: ReactNode\n  readonly searchPlaceholder?: string\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\n/** Row heights the windowing uses; rows are fixed-height in both layouts. */\nexport const PRESET_BROWSER_ROW_HEIGHT = { wide: 32, narrow: 44 } as const\n/** Lists longer than this render only the rows in view (plus overscan). */\nexport const PRESET_BROWSER_WINDOW_MIN = 120\nconst OVERSCAN = 12\nconst PAGE = 10\nconst HEADER_HEIGHT = 28\n\nconst presetCount = (count: number) =>\n  `${count} ${count === 1 ? \"preset\" : \"presets\"}`\n\n/** The rows to render for a scroll position: everything for a short list. */\nexport function presetBrowserWindow(\n  count: number,\n  scrollTop: number,\n  viewportHeight: number,\n  rowHeight: number,\n  headerHeight = 0\n): { readonly start: number; readonly end: number } {\n  if (count <= PRESET_BROWSER_WINDOW_MIN) return { start: 0, end: count }\n  const visible =\n    viewportHeight > 0 ? Math.ceil(viewportHeight / rowHeight) : 40\n  const first = Math.floor(Math.max(0, scrollTop - headerHeight) / rowHeight)\n  const start = Math.max(0, first - OVERSCAN)\n  return { start, end: Math.min(count, first + visible + OVERSCAN) }\n}\n\nfunction Chevron({ direction }: { readonly direction: -1 | 1 }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n    >\n      <path\n        d={direction < 0 ? \"m9.5 3.5-5 4.5 5 4.5\" : \"m6.5 3.5 5 4.5-5 4.5\"}\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n\nfunction isTextInput(target: EventTarget | null): boolean {\n  return (\n    target instanceof Element &&\n    target.closest('input,textarea,select,[contenteditable=\"true\"]') !== null\n  )\n}\n\nexport function PresetBrowser({\n  library,\n  controller: shared,\n  open: controlledOpen,\n  onOpenChange,\n  container: explicitContainer,\n  layout = \"auto\",\n  narrowWidth = 640,\n  variant = \"quiet\",\n  title = \"Presets\",\n  searchPlaceholder = \"Search presets…\",\n  className,\n  style,\n}: PresetBrowserProps) {\n  const own = usePresetLibrary(library)\n  const controller = shared ?? own\n  const open = controlledOpen ?? controller.browserOpen\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (onOpenChange) onOpenChange(next)\n      else controller.setBrowserOpen(next)\n    },\n    [controller, onOpenChange]\n  )\n  const { anchorRef, container, contained } = usePresetOverlayTarget(\n    open,\n    explicitContainer\n  )\n  const [sizeRef, size] = useElementSize<HTMLDivElement>()\n  const resolvedLayout: PresetBrowserLayout =\n    layout === \"auto\"\n      ? size.width > 0 && size.width >= narrowWidth\n        ? \"wide\"\n        : \"narrow\"\n      : layout\n  const narrow = resolvedLayout === \"narrow\"\n  const rowHeight = PRESET_BROWSER_ROW_HEIGHT[resolvedLayout]\n  const headerHeight = narrow ? 0 : HEADER_HEIGHT\n\n  const {\n    filtered,\n    entries,\n    scopes,\n    tagCategory,\n    actions,\n    activeId,\n    activeEntry,\n    dirty,\n    showSecondary,\n    effectiveState,\n  } = controller\n  const vocabulary = library.tagVocabulary\n  const hasTags = tagCategory.tags.length > 0\n  const baseId = useId()\n  const gridId = `${baseId}-grid`\n  const rowId = (index: number) => `${baseId}-row-${index}`\n\n  // The highlighted row (keyboard cursor), distinct from the host's active id.\n  const [cursorId, setCursorId] = useState<string | null>(null)\n  const [drawer, setDrawer] = useState<\"groups\" | \"tags\" | null>(null)\n  const [confirmDelete, setConfirmDelete] = useState(false)\n  const [saveOpen, setSaveOpen] = useState(false)\n  const [scrollTop, setScrollTop] = useState(0)\n  const [gridRef, gridSize] = useElementSize<HTMLDivElement>()\n  const gridNode = useRef<HTMLDivElement | null>(null)\n  const searchRef = useRef<HTMLInputElement>(null)\n  const pendingSelect = useRef<{ id: string; at: number } | null>(null)\n\n  const cursorIndex = useMemo(() => {\n    if (cursorId === null) return -1\n    return filtered.findIndex((entry) => entry.id === cursorId)\n  }, [cursorId, filtered])\n\n  // Opening starts at the active entry; a filter that hides the cursor moves\n  // it to the first visible row.\n  useLayoutEffect(() => {\n    if (!open) {\n      setCursorId(null)\n      setScrollTop(0)\n      setDrawer(null)\n      setConfirmDelete(false)\n      setSaveOpen(false)\n      return\n    }\n    setCursorId((current) => {\n      if (current !== null && filtered.some((entry) => entry.id === current))\n        return current\n      if (activeId !== null && filtered.some((entry) => entry.id === activeId))\n        return activeId\n      return filtered[0]?.id ?? null\n    })\n  }, [activeId, filtered, open])\n  useEffect(() => setConfirmDelete(false), [activeId])\n\n  const scrollToIndex = useCallback(\n    (index: number) => {\n      const grid = gridNode.current\n      if (!grid || index < 0) return\n      const top = headerHeight + index * rowHeight\n      const bottom = top + rowHeight\n      const view = grid.clientHeight\n      let next = grid.scrollTop\n      if (top - headerHeight < next) next = top - headerHeight\n      else if (bottom > next + view)\n        // Unmeasured (no layout yet): align the row to the top.\n        next = view > 0 ? bottom - view : top - headerHeight\n      if (next === grid.scrollTop) return\n      grid.scrollTop = next\n      // The window follows the intended position even before layout has\n      // produced a scrollable height (the browser clamps; read it back).\n      setScrollTop(grid.scrollTop || next)\n    },\n    [headerHeight, rowHeight]\n  )\n\n  // Reveal the starting row once per opening.\n  const revealOnOpen = useRef(false)\n  useLayoutEffect(() => {\n    if (open) revealOnOpen.current = true\n  }, [open])\n  useLayoutEffect(() => {\n    if (!revealOnOpen.current || cursorIndex < 0) return\n    revealOnOpen.current = false\n    scrollToIndex(cursorIndex)\n  }, [cursorIndex, scrollToIndex])\n\n  const moveCursor = useCallback(\n    (index: number) => {\n      if (filtered.length === 0) return\n      const clamped = Math.max(0, Math.min(filtered.length - 1, index))\n      setCursorId(filtered[clamped]!.id)\n      scrollToIndex(clamped)\n    },\n    [filtered, scrollToIndex]\n  )\n\n  const requestSelect = useCallback(\n    (id: string) => {\n      const now = Date.now()\n      const pending = pendingSelect.current\n      // One request per entry within a double-click window.\n      if (pending && pending.id === id && now - pending.at < 500) return\n      if (id === library.activeId && !library.dirty) return\n      pendingSelect.current = { id, at: now }\n      controller.select(id)\n    },\n    [controller, library.activeId, library.dirty]\n  )\n\n  const stepTo = (direction: \"next\" | \"previous\") => {\n    const id = controller.adjacent(direction)\n    if (id === null) return\n    requestSelect(id)\n    setCursorId(id)\n    scrollToIndex(filtered.findIndex((entry) => entry.id === id))\n  }\n\n  const commit = (id: string | null | undefined, close: boolean) => {\n    if (!id) return\n    requestSelect(id)\n    setCursorId(id)\n    if (close) setOpen(false)\n  }\n\n  const onListKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n    const inSearch = event.currentTarget === searchRef.current\n    const current = cursorIndex\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        moveCursor(current < 0 ? 0 : current + 1)\n        return\n      case \"ArrowUp\":\n        event.preventDefault()\n        moveCursor(current < 0 ? filtered.length - 1 : current - 1)\n        return\n      case \"PageDown\":\n        event.preventDefault()\n        moveCursor(Math.max(0, current) + PAGE)\n        return\n      case \"PageUp\":\n        event.preventDefault()\n        moveCursor(Math.max(0, current) - PAGE)\n        return\n      case \"Home\":\n      case \"End\":\n        if (inSearch) return\n        event.preventDefault()\n        moveCursor(event.key === \"Home\" ? 0 : filtered.length - 1)\n        return\n      case \"Enter\":\n        event.preventDefault()\n        commit(filtered[Math.max(0, current)]?.id, true)\n        return\n      case \" \":\n        if (inSearch) return\n        event.preventDefault()\n        if (!event.repeat) commit(filtered[current]?.id, false)\n        return\n      case \"f\":\n      case \"F\":\n        if (inSearch || event.metaKey || event.ctrlKey || event.altKey) return\n        event.preventDefault()\n        if (actions.favorite && filtered[current])\n          controller.toggleFavorite(filtered[current]!.id)\n        return\n      case \"Escape\":\n        if (inSearch && effectiveState.query !== \"\") {\n          event.preventDefault()\n          event.stopPropagation()\n          controller.setQuery(\"\")\n        }\n        return\n      default:\n    }\n  }\n\n  const onPopupKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n    if (event.key === \"/\" && !isTextInput(event.target)) {\n      event.preventDefault()\n      searchRef.current?.focus()\n    }\n  }\n\n  const rows = presetBrowserWindow(\n    filtered.length,\n    scrollTop,\n    gridSize.height,\n    rowHeight,\n    headerHeight\n  )\n  const columns = [\n    \"minmax(0,2fr)\",\n    ...(narrow ? [] : [\"minmax(0,1fr)\"]),\n    ...(!narrow && showSecondary ? [\"minmax(0,1fr)\"] : []),\n    ...(narrow ? [] : [\"minmax(0,1.2fr)\"]),\n    ...(actions.favorite ? [\"1.75rem\"] : []),\n  ].join(\" \")\n  const groupHeader = library.groupLabel ?? \"Group\"\n  const secondaryHeader = library.secondaryLabel ?? \"Details\"\n  const tagValue: TagSelection =\n    effectiveState.tags.length > 0\n      ? { [PRESET_LIBRARY_TAG_CATEGORY]: effectiveState.tags }\n      : {}\n  const canDelete = controller.canDeleteActive\n  const position = contained ? \"absolute\" : \"fixed\"\n\n  const scopeButton = (row: PresetLibraryScopeRow) => (\n    <button\n      key={row.key}\n      type=\"button\"\n      data-pb-scope={row.key}\n      data-builtin={row.scope.kind !== \"group\" || undefined}\n      aria-pressed={row.selected}\n      onClick={() => {\n        controller.setScope(row.scope)\n        setDrawer(null)\n      }}\n    >\n      <span data-pb-scope-label=\"\">{row.label}</span>\n      <span data-pb-scope-count=\"\">\n        <span aria-hidden=\"true\">{row.count}</span>\n        <span data-pb-hidden=\"\">{`, ${presetCount(row.count)}`}</span>\n      </span>\n    </button>\n  )\n\n  const groupsColumn = (\n    <>\n      <p data-pb-column-label=\"\" id={`${baseId}-groups`}>\n        {library.groupLabel ?? \"Groups\"}\n      </p>\n      <div\n        role=\"group\"\n        aria-labelledby={`${baseId}-groups`}\n        style={{ display: \"contents\" }}\n      >\n        {scopes.map(scopeButton)}\n      </div>\n    </>\n  )\n\n  const tagsColumn = hasTags ? (\n    <TagBrowser\n      categories={[tagCategory]}\n      value={tagValue}\n      onChange={(next) =>\n        controller.setTags(next[PRESET_LIBRARY_TAG_CATEGORY] ?? [])\n      }\n      layout=\"stacked\"\n      disableZeroCount\n      label=\"Tags\"\n      variant=\"quiet\"\n    />\n  ) : null\n\n  const renderRow = (entry: PresetLibraryEntry, index: number) => {\n    const active = entry.id === activeId\n    const tagLabels = presetEntryTagLabels(entry, vocabulary)\n    const subtitle = [entry.group.label, entry.secondary ?? \"\"]\n      .filter((part) => part !== \"\")\n      .join(\" · \")\n    return (\n      <div\n        key={entry.id}\n        id={rowId(index)}\n        role=\"row\"\n        aria-selected={active}\n        aria-rowindex={index + 2}\n        data-pb-row=\"\"\n        data-preset-id={entry.id}\n        data-cursor={entry.id === cursorId || undefined}\n        data-source={entry.source}\n        style={{ height: rowHeight }}\n        onClick={() => commit(entry.id, false)}\n        onDoubleClick={() => commit(entry.id, true)}\n      >\n        <span role=\"gridcell\" data-pb-cell=\"name\">\n          {entry.name}\n          {active && dirty ? (\n            <span data-pb-dirty=\"\">\n              {\" ●\"}\n              <span data-pb-hidden=\"\"> (modified)</span>\n            </span>\n          ) : null}\n          {narrow && subtitle ? (\n            <span data-pb-subtitle=\"\">{subtitle}</span>\n          ) : null}\n        </span>\n        {narrow ? null : (\n          <span role=\"gridcell\" data-pb-cell=\"group\">\n            {entry.group.label}\n          </span>\n        )}\n        {!narrow && showSecondary ? (\n          <span\n            role=\"gridcell\"\n            data-pb-cell=\"secondary\"\n          >\n            {entry.secondary ?? \"\"}\n          </span>\n        ) : null}\n        {narrow ? null : (\n          <span\n            role=\"gridcell\"\n            data-pb-cell=\"tags\"\n          >\n            {tagLabels.join(\", \")}\n          </span>\n        )}\n        {actions.favorite ? (\n          <span role=\"gridcell\" data-pb-cell=\"favorite\">\n            <button\n              type=\"button\"\n              tabIndex={-1}\n              data-pb-favorite=\"\"\n              aria-pressed={entry.favorite === true}\n              aria-label={`${entry.favorite ? \"Remove\" : \"Add\"} ${entry.name} ${entry.favorite ? \"from\" : \"to\"} favorites`}\n              onClick={(event) => {\n                event.stopPropagation()\n                controller.toggleFavorite(entry.id)\n              }}\n              onDoubleClick={(event) => event.stopPropagation()}\n            >\n              <span aria-hidden=\"true\">{entry.favorite ? \"♥\" : \"♡\"}</span>\n            </button>\n          </span>\n        ) : null}\n      </div>\n    )\n  }\n\n  const activeScope = scopes.find((row) => row.selected)\n  const chips =\n    narrow &&\n    ((activeScope && activeScope.scope.kind !== \"all\") ||\n      effectiveState.tags.length > 0) ? (\n      <div data-pb-chips=\"\" aria-label=\"Active filters\" role=\"group\">\n        {activeScope && activeScope.scope.kind !== \"all\" ? (\n          <button\n            type=\"button\"\n            data-pb-chip=\"scope\"\n            aria-label={`Remove filter ${activeScope.label}`}\n            onClick={() => controller.setScope({ kind: \"all\" })}\n          >\n            {activeScope.label} <span aria-hidden=\"true\">×</span>\n          </button>\n        ) : null}\n        {effectiveState.tags.map((tag) => (\n          <button\n            key={tag}\n            type=\"button\"\n            data-pb-chip=\"tag\"\n            aria-label={`Remove tag ${vocabulary.find((item) => item.id === tag)?.label ?? tag}`}\n            onClick={() => controller.toggleTag(tag)}\n          >\n            {vocabulary.find((item) => item.id === tag)?.label ?? tag}{\" \"}\n            <span aria-hidden=\"true\">×</span>\n          </button>\n        ))}\n      </div>\n    ) : null\n\n  return (\n    <>\n      <span ref={anchorRef} hidden data-preset-browser-anchor=\"\" />\n      <Dialog.Root\n        open={open}\n        modal=\"trap-focus\"\n        onOpenChange={(next) => setOpen(next)}\n      >\n        {open && container ? (\n          <Dialog.Portal container={container}>\n            <Dialog.Popup\n              ref={sizeRef}\n              data-preset-browser=\"\"\n              data-variant={variant}\n              data-layout={resolvedLayout}\n              data-has-tags={hasTags ? \"true\" : \"false\"}\n              data-contained={contained || undefined}\n              className={className}\n              initialFocus={searchRef}\n              onKeyDown={onPopupKeyDown}\n              style={{ position, ...style }}\n            >\n              <style data-preset-browser-styles=\"\">\n                {PRESET_BROWSER_STYLES}\n              </style>\n              <div\n                data-pb-toolbar=\"\"\n                role=\"toolbar\"\n                aria-label=\"Preset actions\"\n              >\n                <Dialog.Title data-pb-title=\"\">{title}</Dialog.Title>\n                <span data-pb-current=\"\" aria-live=\"polite\" aria-atomic=\"true\">\n                  <span data-pb-current-name=\"\">\n                    {activeEntry?.name ?? \"Custom\"}\n                  </span>\n                  {dirty ? (\n                    <span data-pb-dirty=\"\">\n                      ●<span data-pb-hidden=\"\"> (modified)</span>\n                    </span>\n                  ) : null}\n                </span>\n                <button\n                  type=\"button\"\n                  data-pb-button=\"\"\n                  data-icon=\"\"\n                  data-pb-step=\"previous\"\n                  aria-label=\"Previous preset\"\n                  disabled={filtered.length === 0}\n                  onClick={() => stepTo(\"previous\")}\n                >\n                  <Chevron direction={-1} />\n                </button>\n                <button\n                  type=\"button\"\n                  data-pb-button=\"\"\n                  data-icon=\"\"\n                  data-pb-step=\"next\"\n                  aria-label=\"Next preset\"\n                  disabled={filtered.length === 0}\n                  onClick={() => stepTo(\"next\")}\n                >\n                  <Chevron direction={1} />\n                </button>\n                {actions.save || actions.overwrite ? (\n                  <button\n                    type=\"button\"\n                    data-pb-button=\"\"\n                    data-pb-action=\"save\"\n                    onClick={() => setSaveOpen(true)}\n                  >\n                    Save…\n                  </button>\n                ) : null}\n                {actions.delete ? (\n                  <button\n                    type=\"button\"\n                    data-pb-button=\"\"\n                    data-pb-action=\"delete\"\n                    data-armed={confirmDelete || undefined}\n                    disabled={!canDelete}\n                    onClick={() => {\n                      if (!canDelete || activeId === null) return\n                      if (!confirmDelete) {\n                        setConfirmDelete(true)\n                        return\n                      }\n                      setConfirmDelete(false)\n                      controller.deleteEntry(activeId)\n                    }}\n                    onBlur={() => setConfirmDelete(false)}\n                  >\n                    {confirmDelete ? \"Confirm delete\" : \"Delete\"}\n                  </button>\n                ) : null}\n                <Dialog.Close\n                  type=\"button\"\n                  data-pb-button=\"\"\n                  data-icon=\"\"\n                  data-pb-close=\"\"\n                  aria-label=\"Close preset browser\"\n                >\n                  <span aria-hidden=\"true\">✕</span>\n                </Dialog.Close>\n              </div>\n              <div data-pb-body=\"\">\n                {narrow ? null : (\n                  <nav\n                    data-pb-groups=\"\"\n                    aria-label={library.groupLabel ?? \"Groups\"}\n                  >\n                    {groupsColumn}\n                  </nav>\n                )}\n                <section data-pb-main=\"\" aria-label=\"Preset list\">\n                  <div data-pb-searchbar=\"\">\n                    <input\n                      ref={searchRef}\n                      type=\"search\"\n                      role=\"combobox\"\n                      aria-expanded=\"true\"\n                      aria-controls={gridId}\n                      aria-autocomplete=\"list\"\n                      aria-activedescendant={\n                        cursorIndex >= 0 ? rowId(cursorIndex) : undefined\n                      }\n                      aria-label=\"Search presets\"\n                      placeholder={searchPlaceholder}\n                      value={effectiveState.query}\n                      data-pb-search=\"\"\n                      onChange={(event) =>\n                        controller.setQuery(event.target.value)\n                      }\n                      onKeyDown={onListKeyDown}\n                    />\n                    {narrow ? (\n                      <>\n                        <button\n                          type=\"button\"\n                          data-pb-button=\"\"\n                          data-pb-drawer-toggle=\"groups\"\n                          aria-expanded={drawer === \"groups\"}\n                          onClick={() =>\n                            setDrawer((current) =>\n                              current === \"groups\" ? null : \"groups\"\n                            )\n                          }\n                        >\n                          {library.groupLabel ?? \"Groups\"}\n                        </button>\n                        {hasTags ? (\n                          <button\n                            type=\"button\"\n                            data-pb-button=\"\"\n                            data-pb-drawer-toggle=\"tags\"\n                            aria-expanded={drawer === \"tags\"}\n                            onClick={() =>\n                              setDrawer((current) =>\n                                current === \"tags\" ? null : \"tags\"\n                              )\n                            }\n                          >\n                            Tags\n                            {effectiveState.tags.length > 0\n                              ? ` (${effectiveState.tags.length})`\n                              : \"\"}\n                          </button>\n                        ) : null}\n                      </>\n                    ) : null}\n                  </div>\n                  {chips}\n                  <div\n                    ref={(node) => {\n                      gridNode.current = node\n                      gridRef(node)\n                    }}\n                    id={gridId}\n                    role=\"grid\"\n                    aria-label=\"Presets\"\n                    aria-rowcount={filtered.length + 1}\n                    aria-activedescendant={\n                      cursorIndex >= 0 ? rowId(cursorIndex) : undefined\n                    }\n                    tabIndex={0}\n                    data-pb-grid=\"\"\n                    style={{ \"--pb-columns\": columns } as CSSProperties}\n                    onKeyDown={onListKeyDown}\n                    onScroll={(event) =>\n                      setScrollTop(event.currentTarget.scrollTop)\n                    }\n                  >\n                    {narrow ? null : (\n                      <div\n                        role=\"row\"\n                        aria-rowindex={1}\n                        data-pb-row=\"\"\n                        data-header=\"\"\n                        style={{ height: HEADER_HEIGHT }}\n                      >\n                        <span role=\"columnheader\">Name</span>\n                        <span role=\"columnheader\">{groupHeader}</span>\n                        {showSecondary ? (\n                          <span role=\"columnheader\">{secondaryHeader}</span>\n                        ) : null}\n                        <span role=\"columnheader\">Tags</span>\n                        {actions.favorite ? (\n                          <span role=\"columnheader\">\n                            <span data-pb-hidden=\"\">Favorite</span>\n                          </span>\n                        ) : null}\n                      </div>\n                    )}\n                    {filtered.length === 0 ? (\n                      <p data-pb-empty=\"\">\n                        {entries.length === 0\n                          ? \"No presets yet.\"\n                          : effectiveState.query.trim() !== \"\" &&\n                              effectiveState.tags.length === 0 &&\n                              activeScope?.scope.kind === \"all\"\n                            ? `No presets match “${effectiveState.query.trim()}”.`\n                            : \"No presets match these filters.\"}\n                      </p>\n                    ) : (\n                      <>\n                        {rows.start > 0 ? (\n                          <div\n                            role=\"presentation\"\n                            style={{ height: rows.start * rowHeight }}\n                          />\n                        ) : null}\n                        {filtered\n                          .slice(rows.start, rows.end)\n                          .map((entry, offset) =>\n                            renderRow(entry, rows.start + offset)\n                          )}\n                        {rows.end < filtered.length ? (\n                          <div\n                            role=\"presentation\"\n                            style={{\n                              height: (filtered.length - rows.end) * rowHeight,\n                            }}\n                          />\n                        ) : null}\n                      </>\n                    )}\n                  </div>\n                  <p data-pb-status=\"\" role=\"status\" aria-live=\"polite\">\n                    {filtered.length === entries.length\n                      ? presetCount(entries.length)\n                      : `${filtered.length} of ${presetCount(entries.length)}`}\n                  </p>\n                </section>\n                {!narrow && hasTags ? (\n                  <aside data-pb-tags=\"\" aria-label=\"Tags\">\n                    {tagsColumn}\n                  </aside>\n                ) : null}\n                {narrow && drawer === \"groups\" ? (\n                  <div data-pb-drawer=\"groups\">{groupsColumn}</div>\n                ) : null}\n                {narrow && drawer === \"tags\" && hasTags ? (\n                  <div data-pb-drawer=\"tags\">\n                    <div data-pb-drawer-header=\"\">\n                      <span data-pb-column-label=\"\">Tags</span>\n                      <button\n                        type=\"button\"\n                        data-pb-button=\"\"\n                        onClick={() => setDrawer(null)}\n                      >\n                        Done\n                      </button>\n                    </div>\n                    {tagsColumn}\n                  </div>\n                ) : null}\n              </div>\n              {/* Nested in the popup so Base UI stacks its focus trap above\n                  this one; it resolves the same face container. */}\n              <PresetSaveDialog\n                library={library}\n                open={saveOpen}\n                onOpenChange={setSaveOpen}\n                variant={variant === \"quiet\" ? \"panel\" : variant}\n              />\n            </Dialog.Popup>\n          </Dialog.Portal>\n        ) : null}\n      </Dialog.Root>\n    </>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/preset-browser.tsx"
    },
    {
      "path": "components/ui/preset-library-selector.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/preset-browser\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * PresetLibrarySelector — the compact `PresetSelector` driven by a host's\n * `AudioModuleFacePresetLibrary`, with its `PresetBrowser` and\n * `PresetSaveDialog`, all sharing one `usePresetLibrary` controller.\n *\n *  - The dropdown lists every entry; the label is the host's `activeId`\n *    (optimistic while a selection is pending) with the modified marker when\n *    the host reports `dirty`.\n *  - Previous/next walk the CURRENT filtered list (the browser's search,\n *    scope and tags), through `adjacentPresetId`.\n *  - `scopeGroupId` narrows the dropdown and previous/next to one group's\n *    entries (`presetLibraryScopedEntries`); Browse still shows everything.\n *  - Save opens the save dialog when the host can save or overwrite.\n *  - Browse opens the browser when `capabilities.browse` is set, the library\n *    has more than `browseThreshold` (30) entries, or `browse` forces it.\n *    `browseButton={false}` keeps the browser but drops the button, for a\n *    face that opens it from elsewhere (a navigator's expand button) through\n *    a shared controller.\n *  - Stars toggle `entry.favorite` through `library.toggleFavorite`.\n *\n * Faces get this automatically from `FaceModuleNavbar` and `SchemaFace` when\n * the host passes `presetLibrary`. Both overlays render inside the nearest\n * face container (`[data-preset-overlay-container]` or\n * `[data-module-face-surface]`).\n */\n\nimport { useMemo, useRef, type ReactNode } from \"react\"\nimport type { AudioModuleFacePresetLibrary } from \"maruaudio/ui/shared/maru/contracts/preset-library\"\nimport {\n  PresetSelector,\n  type PresetFavoritesSource,\n  type PresetSelectorProps,\n} from \"@/components/ui/preset-selector\"\nimport {\n  presetLibraryScopedEntries,\n  presetLibrarySelectorOptions,\n  presetLibraryStepTarget,\n} from \"maruaudio/ui/shared/preset-library-model\"\nimport {\n  usePresetLibrary,\n  type PresetLibraryController,\n} from \"maruaudio/ui/shared/use-preset-library\"\nimport {\n  PresetBrowser,\n  type PresetBrowserLayout,\n} from \"@/components/ui/preset-browser\"\nimport { PresetSaveDialog } from \"@/components/ui/preset-save-dialog\"\nimport type { PresetBrowserVariant } from \"maruaudio/ui/shared/preset-browser-styles\"\n\nexport interface PresetLibrarySelectorProps extends Omit<\n  PresetSelectorProps,\n  | \"options\"\n  | \"value\"\n  | \"onValueChange\"\n  | \"dirty\"\n  | \"onSave\"\n  | \"onBrowse\"\n  | \"resolveStep\"\n  | \"favorites\"\n  | \"favoriteScope\"\n> {\n  readonly library: AudioModuleFacePresetLibrary\n  /** Share a controller created with `usePresetLibrary` by the caller. */\n  readonly controller?: PresetLibraryController\n  /** Force Browse on or off. Undefined: capability or size. */\n  readonly browse?: boolean\n  /** Entry count above which Browse is offered. Defaults to 30. */\n  readonly browseThreshold?: number\n  /** Remember the viewer's browser scope and tags under this key. */\n  readonly persistKey?: string\n  /** Render the overlays into this element instead of the face container. */\n  readonly container?: HTMLElement | null\n  readonly browserVariant?: PresetBrowserVariant\n  readonly browserLayout?: \"auto\" | PresetBrowserLayout\n  readonly browserTitle?: ReactNode\n  /** Keep the dropdown and previous/next inside this group's entries. */\n  readonly scopeGroupId?: string | null\n  /** Show the Browse button beside the selector. Defaults to true; false\n   *  still renders the browser, opened through the controller. */\n  readonly browseButton?: boolean\n}\n\nexport function PresetLibrarySelector({\n  library,\n  controller: shared,\n  browse,\n  browseThreshold,\n  persistKey,\n  container,\n  browserVariant,\n  browserLayout,\n  browserTitle,\n  scopeGroupId,\n  browseButton = true,\n  ...selector\n}: PresetLibrarySelectorProps) {\n  const own = usePresetLibrary(library, { browse, browseThreshold, persistKey })\n  const controller = shared ?? own\n  const scoped = useMemo(\n    () => (scopeGroupId == null ? undefined : presetLibraryScopedEntries(library, scopeGroupId)),\n    [library, scopeGroupId]\n  )\n  const options = useMemo(\n    () => presetLibrarySelectorOptions(scoped === undefined ? library : { entries: scoped, groups: library.groups }),\n    [library, scoped]\n  )\n  const latest = useRef(library)\n  latest.current = library\n  const canFavorite = controller.actions.favorite\n  const favoriteSignature = library.entries\n    .filter((entry) => entry.favorite === true)\n    .map((entry) => entry.id)\n    .join(\"\\n\")\n  // The selector's star column reads favorites through its async source\n  // contract; the library already carries them on its entries.\n  const favorites = useMemo<PresetFavoritesSource | undefined>(() => {\n    if (!canFavorite) return undefined\n    const ids = new Set(\n      favoriteSignature === \"\" ? [] : favoriteSignature.split(\"\\n\")\n    )\n    return {\n      load: () => Promise.resolve(ids),\n      setFavorite: async (_scope, id, favorite) => {\n        if (ids.has(id) !== favorite) latest.current.toggleFavorite?.(id)\n      },\n    }\n  }, [canFavorite, favoriteSignature])\n  const canSave = controller.actions.save || controller.actions.overwrite\n\n  return (\n    <>\n      <PresetSelector\n        {...selector}\n        options={options}\n        value={library.activeId ?? undefined}\n        onValueChange={controller.select}\n        dirty={library.dirty}\n        favorites={favorites}\n        favoriteScope=\"preset-library\"\n        onSave={canSave ? controller.openSaveDialog : undefined}\n        onBrowse={controller.canBrowse && browseButton ? controller.openBrowser : undefined}\n        resolveStep={(delta, current) =>\n          (scoped === undefined\n            ? controller.adjacent(delta < 0 ? \"previous\" : \"next\", current ?? null)\n            : presetLibraryStepTarget(scoped, current ?? null, delta < 0 ? \"previous\" : \"next\")) ?? undefined\n        }\n      />\n      {controller.canBrowse ? (\n        <PresetBrowser\n          library={library}\n          controller={controller}\n          container={container}\n          variant={browserVariant}\n          layout={browserLayout}\n          title={browserTitle}\n        />\n      ) : null}\n      {canSave ? (\n        <PresetSaveDialog\n          library={library}\n          open={controller.saveOpen}\n          onOpenChange={controller.setSaveOpen}\n          container={container}\n          variant={browserVariant === \"glass\" ? \"glass\" : \"panel\"}\n        />\n      ) : null}\n    </>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/preset-library-selector.tsx"
    },
    {
      "path": "components/ui/preset-library-navigator.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/preset-browser\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * PresetLibraryNavigator — the preset library as a sidebar beside a face,\n * the way Kontakt and Komplete Kontrol list their libraries on the left: the\n * face stays playable while the player walks the collection.\n *\n * One sidebar that grows with its width (`presetNavigatorColumns`):\n *\n *  - **Narrow** (the default sidebar): two levels, drilled into rather than\n *    tabbed. **Groups** (a sampler's instruments) as a list with thumbnails\n *    or as picture cards (`group.artwork`, else a placeholder from its\n *    accent); opening one shows its **presets** under its picture, with the\n *    way back on it. The face's own header (the `header` slot) already names\n *    what is loaded, so nothing below repeats it.\n *  - **Wide**: the groups and the open group's presets side by side, so\n *    walking instruments never leaves the presets. Half and half at first;\n *    the divider between them drags (double-click resets it), remembered\n *    under `persistKey`.\n *  - **Widest**, when the face allows it (`maxColumns={3}`) and the library\n *    has tags: the tags as a third column, narrowing groups and presets.\n *\n * Opening a group lands on its first preset — or keeps the active one when\n * it is already there, so nothing reloads. A preset click selects and the\n * navigator stays, to audition the next; `onChoose` tells the face (a face\n * shown full width may step back to its sidebar).\n *\n * A search over the whole library replaces the groups and presets while it\n * has text: matching groups first, then matching presets under their group.\n * It matches names, groups, authors and tags (by id or label). It waits as\n * a magnifier button, so it draws less attention than the list; the button\n * or `/` anywhere in the face opens and focuses it, and leaving it empty\n * closes it again.\n *\n * `onExpand` (default: the shared controller's browser) is the toolbar's\n * Expand button; with `expanded` it shows as Collapse, and Escape outside\n * the search calls it too. The list-or-cards choice is the viewer's,\n * remembered under `persistKey`.\n *\n * Keyboard: `/` focuses the search, arrows move between a column's items\n * (all four in the cards grid), Home and End jump, Enter/Space activate,\n * Escape in the search clears it, then closes it. Every\n * selection is a request to the host's library; the navigator persists only\n * the layout choice and the divider.\n */\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\"\nimport {\n  presetLibraryTagCounts,\n  type AudioModuleFacePresetLibrary,\n  type PresetLibraryEntry,\n} from \"maruaudio/ui/shared/maru/contracts/preset-library\"\nimport { useElementSize } from \"maruaudio/ui/shared/use-element-size\"\nimport {\n  PRESET_LIBRARY_TAG_CATEGORY,\n  presetLibraryTagCategory,\n} from \"maruaudio/ui/shared/preset-library-model\"\nimport {\n  presetLibraryGroupLanding,\n  presetLibraryNavigatorGroups,\n  presetLibraryNavigatorMatches,\n  presetLibraryNavigatorSearch,\n  presetNavigatorColumns,\n  type PresetNavigatorColumns,\n} from \"maruaudio/ui/shared/preset-library-navigator-model\"\nimport {\n  GroupArt,\n  GroupItem,\n  moveNavigatorFocus,\n  NAVIGATOR_ICON as ICON,\n  PresetItem,\n  type PresetNavigatorLayout,\n} from \"@/components/ui/preset-library-navigator-parts\"\nimport type { PresetLibraryController } from \"maruaudio/ui/shared/use-preset-library\"\nimport { PRESET_NAVIGATOR_STYLES } from \"maruaudio/ui/shared/preset-navigator-styles\"\nimport { TagBrowser } from \"@/components/ui/tag-browser\"\nimport { ResizeHandle } from \"@/components/ui/resize-handle\"\n\nexport { presetGroupPlaceholder, type PresetNavigatorLayout } from \"@/components/ui/preset-library-navigator-parts\"\nexport type PresetNavigatorTab = \"groups\" | \"presets\"\n\nexport interface PresetLibraryNavigatorProps {\n  readonly library: AudioModuleFacePresetLibrary\n  /** The shared controller: its browser is what Expand opens by default. */\n  readonly controller?: PresetLibraryController\n  /** The group the face has mounted: the navigator opens on its presets and\n   *  follows it when the host mounts another. */\n  readonly currentGroupId?: string | null\n  /** The face's own content at the top, above the search. */\n  readonly header?: ReactNode\n  /** After the groups: the face's way to add one (a sampler's \"Add\n   *  instrument\" — a bank file, folder or link). */\n  readonly addGroup?: ReactNode\n  /** Expand (or, when `expanded`, collapse). Default: the controller's\n   *  browser, when it can browse. */\n  readonly onExpand?: () => void\n  /** The face shows the navigator expanded: Expand reads as Collapse and\n   *  Escape outside the search collapses. */\n  readonly expanded?: boolean\n  /** A preset the player chose (after the request to select it). */\n  readonly onChoose?: (entry: PresetLibraryEntry) => void\n  /** The most columns the width may open: 1 always drills down, 2 (the\n   *  default) puts groups beside presets, 3 adds the tags. */\n  readonly maxColumns?: PresetNavigatorColumns\n  /** Remember the list-or-cards choice under this key. */\n  readonly persistKey?: string\n  readonly defaultLayout?: PresetNavigatorLayout\n  readonly labels?: {\n    /** The groups (\"Instruments\" for a sampler). */\n    readonly groups?: string\n    /** The way back to the groups. Default: the groups label. */\n    readonly back?: string\n    /** The presets column, side by side. */\n    readonly presets?: string\n    readonly search?: string\n    readonly expand?: string\n    readonly collapse?: string\n    readonly tags?: string\n    readonly navigator?: string\n  }\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\nconst STORAGE_PREFIX = \"maru.preset-navigator.\"\nconst NO_TAGS: readonly string[] = []\n/** The narrowest a side-by-side column gets, px. */\nconst COLUMN_MIN = 160\n/** The divider's hit area between the columns, px (`ResizeHandle`'s),\n *  centred on their boundary. */\nconst DIVIDER = 8\n\nfunction readStored(key: string | undefined): string | null {\n  if (!key) return null\n  try {\n    return globalThis.localStorage?.getItem(STORAGE_PREFIX + key) ?? null\n  } catch {\n    return null\n  }\n}\n\nfunction writeStored(key: string | undefined, value: string): void {\n  if (!key) return\n  try {\n    globalThis.localStorage?.setItem(STORAGE_PREFIX + key, value)\n  } catch {\n    // Sandboxed frame or privacy mode: the choice lasts this session only.\n  }\n}\n\nfunction readLayout(key: string | undefined): PresetNavigatorLayout | null {\n  const value = readStored(key)\n  return value === \"list\" || value === \"cards\" ? value : null\n}\n\n/** The groups column's share of the two side-by-side columns. */\nfunction readSplit(key: string | undefined): number {\n  const value = Number(readStored(key === undefined ? undefined : `${key}.split`) ?? NaN)\n  return value > 0 && value < 1 ? value : 0.5\n}\n\n/** Typing into a field: `/` is a character there, not the shortcut. */\nfunction editing(target: EventTarget | null): boolean {\n  const element = target as HTMLElement | null\n  if (element === null || typeof element.tagName !== \"string\") return false\n  return /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName) || element.isContentEditable\n}\n\nexport function PresetLibraryNavigator({\n  library,\n  controller,\n  currentGroupId = null,\n  header,\n  addGroup,\n  onExpand,\n  expanded = false,\n  onChoose,\n  maxColumns = 2,\n  persistKey,\n  defaultLayout = \"cards\",\n  labels,\n  className,\n  style,\n}: PresetLibraryNavigatorProps) {\n  const groupsLabel = labels?.groups ?? \"Groups\"\n  const presetsLabel = labels?.presets ?? \"Presets\"\n  const tagsLabel = labels?.tags ?? \"Tags\"\n  const [layout, setLayoutState] = useState<PresetNavigatorLayout>(\n    () => readLayout(persistKey) ?? defaultLayout\n  )\n  const setLayout = useCallback(\n    (next: PresetNavigatorLayout) => {\n      setLayoutState(next)\n      writeStored(persistKey, next)\n    },\n    [persistKey]\n  )\n  const [sizeRef, size, navElement] = useElementSize<HTMLElement>()\n  const [pairRef, pairSize] = useElementSize<HTMLDivElement>()\n  const [split, setSplit] = useState(() => readSplit(persistKey))\n  const commitSplit = useCallback(\n    (next: number) => {\n      setSplit(next)\n      writeStored(persistKey === undefined ? undefined : `${persistKey}.split`, String(next))\n    },\n    [persistKey]\n  )\n  const hasTags = useMemo(\n    () => library.tagVocabulary.length > 0 || library.entries.some((entry) => entry.tags.length > 0),\n    [library]\n  )\n  const columns = presetNavigatorColumns(size.width, maxColumns, hasTags)\n  const [query, setQuery] = useState(\"\")\n  /* The tag filter applies only while its column shows: kept, not lost,\n   * when the navigator narrows again. */\n  const [pickedTags, setPickedTags] = useState<readonly string[]>(NO_TAGS)\n  const tags = columns === 3 ? pickedTags : NO_TAGS\n  const allGroups = useMemo(\n    () => presetLibraryNavigatorGroups(library, currentGroupId),\n    [library, currentGroupId]\n  )\n  /* What the tags leave: the presets, and the groups that still have some. */\n  const tagged = useMemo(() => presetLibraryNavigatorMatches(library, \"\", tags), [library, tags])\n  const groups = useMemo(() => {\n    if (tags.length === 0) return allGroups\n    const left = new Set(tagged.map((entry) => entry.group.id))\n    return allGroups.filter((group) => left.has(group.id))\n  }, [allGroups, tagged, tags])\n  /* The open group: the mounted one, followed when the host mounts another;\n   * a group the player opened stays open until then. */\n  const [openGroupId, setOpenGroupId] = useState<string | null>(\n    currentGroupId ?? null\n  )\n  const [tab, setTab] = useState<PresetNavigatorTab>(\n    currentGroupId == null ? \"groups\" : \"presets\"\n  )\n  const followed = useRef(currentGroupId)\n  useEffect(() => {\n    if (followed.current === currentGroupId) return\n    followed.current = currentGroupId\n    if (currentGroupId != null) setOpenGroupId(currentGroupId)\n  }, [currentGroupId])\n  const openGroup = allGroups.find((group) => group.id === openGroupId) ?? null\n  const presets = useMemo(\n    () => (openGroup === null ? [] : tagged.filter((entry) => entry.group.id === openGroup.id)),\n    [tagged, openGroup]\n  )\n  const results = useMemo(\n    () => presetLibraryNavigatorSearch(library, query, currentGroupId, tags),\n    [library, query, currentGroupId, tags]\n  )\n  const searching = query.trim() !== \"\"\n  /* The search waits as a button until opened (or `/`). */\n  const [searchOpen, setSearchOpen] = useState(false)\n  const searchRef = useRef<HTMLInputElement | null>(null)\n  const searchButtonRef = useRef<HTMLButtonElement | null>(null)\n  const searchCompact = !searchOpen && query === \"\"\n  const focusSearch = useRef(false)\n  const openSearch = useCallback(() => {\n    focusSearch.current = true\n    setSearchOpen(true)\n    searchRef.current?.focus()\n  }, [])\n  useLayoutEffect(() => {\n    if (searchCompact || !focusSearch.current) return\n    focusSearch.current = false\n    searchRef.current?.focus()\n  }, [searchCompact])\n  useEffect(() => {\n    const doc = navElement?.ownerDocument\n    if (!doc) return\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"/\" || event.metaKey || event.ctrlKey || event.altKey) return\n      if (event.defaultPrevented || editing(event.target)) return\n      event.preventDefault()\n      openSearch()\n    }\n    doc.addEventListener(\"keydown\", onKey)\n    return () => doc.removeEventListener(\"keydown\", onKey)\n  }, [navElement, openSearch])\n  const showing: PresetNavigatorTab = openGroup === null ? \"groups\" : tab\n  const tagCategory = useMemo(() => {\n    if (columns !== 3) return null\n    const counted = presetLibraryNavigatorMatches(library, query, tags)\n    return presetLibraryTagCategory(library, presetLibraryTagCounts(counted), tagsLabel)\n  }, [columns, library, query, tags, tagsLabel])\n\n  const latest = useRef(library)\n  latest.current = library\n  const activate = useCallback((groupId: string) => {\n    setOpenGroupId(groupId)\n    setTab(\"presets\")\n    setQuery(\"\")\n    const landing = presetLibraryGroupLanding(latest.current, groupId)\n    if (landing !== null && landing !== latest.current.activeId) latest.current.select(landing)\n  }, [])\n  const chosen = useRef(onChoose)\n  chosen.current = onChoose\n  const choose = useCallback((entry: PresetLibraryEntry) => {\n    latest.current.select(entry.id)\n    chosen.current?.(entry)\n  }, [])\n\n  const expand =\n    onExpand ?? (controller?.canBrowse ? controller.openBrowser : undefined)\n\n  /* Opening a group (or the tab) brings the active items into view. */\n  const bodyRef = useRef<HTMLDivElement | null>(null)\n  useLayoutEffect(() => {\n    if (searching) return\n    for (const current of bodyRef.current?.querySelectorAll<HTMLElement>(\n      '[data-pn-item][aria-current=\"true\"]'\n    ) ?? [])\n      current.scrollIntoView?.({ block: \"nearest\" })\n  }, [showing, openGroup?.id, layout, searching, columns])\n\n  const groupItem = (group: (typeof groups)[number], view: PresetNavigatorLayout) => (\n    <GroupItem\n      key={group.id}\n      group={group}\n      view={view}\n      open={columns > 1 && group.id === openGroup?.id}\n      onActivate={activate}\n    />\n  )\n  const presetItem = (entry: PresetLibraryEntry, showGroup = false) => (\n    <PresetItem\n      key={entry.id}\n      entry={entry}\n      active={entry.id === library.activeId}\n      dirty={library.dirty}\n      showGroup={showGroup}\n      onChoose={choose}\n    />\n  )\n  const groupsList = (\n    <>\n      <div data-pn-groups={layout}>{groups.map((group) => groupItem(group, layout))}</div>\n      {addGroup === undefined || tags.length > 0 ? null : <div data-pn-add>{addGroup}</div>}\n    </>\n  )\n  const searchResults =\n    results.total === 0 ? (\n      <p data-pn-empty role=\"status\">\n        Nothing matches “{query.trim()}”.\n      </p>\n    ) : (\n      <>\n        {results.groups.length === 0 ? null : (\n          <section data-pn-section>\n            <h3 data-pn-section-label>{groupsLabel}</h3>\n            <div data-pn-groups=\"list\">{results.groups.map((group) => groupItem(group, \"list\"))}</div>\n          </section>\n        )}\n        {results.sections.map(({ group, entries }) => (\n          <section key={group.id} data-pn-section>\n            <h3 data-pn-section-label>{group.label}</h3>\n            <div data-pn-presets>{entries.map((entry) => presetItem(entry, true))}</div>\n          </section>\n        ))}\n      </>\n    )\n  const listsGroups = !searching && (columns > 1 || showing === \"groups\")\n  /* The two side-by-side columns' width (the divider overlaps them). */\n  const pairSpan = pairSize.width\n  const pairId = useId()\n\n  return (\n    <nav\n      ref={sizeRef}\n      data-preset-navigator=\"\"\n      data-layout={layout}\n      data-columns={columns}\n      data-showing={searching ? \"search\" : columns > 1 ? \"columns\" : showing}\n      aria-label={labels?.navigator ?? \"Sound library\"}\n      className={className}\n      style={style}\n      onKeyDown={(event) => {\n        if (event.key !== \"Escape\" || !expanded || expand === undefined || event.defaultPrevented) return\n        event.preventDefault()\n        expand()\n      }}\n    >\n      <style data-maru-ui-kit-portable>{PRESET_NAVIGATOR_STYLES}</style>\n      {header === undefined ? null : <div data-pn-header>{header}</div>}\n      <div data-pn-toolbar>\n        {searchCompact ? (\n          <button\n            ref={searchButtonRef}\n            type=\"button\"\n            data-pn-icon=\"\"\n            data-pn-search-open=\"\"\n            aria-label={labels?.search ?? \"Search sounds\"}\n            aria-keyshortcuts=\"/\"\n            onClick={openSearch}\n          >\n            {ICON.search()}\n          </button>\n        ) : (\n        <label data-pn-searchbox>\n          {ICON.search()}\n          <input\n            ref={searchRef}\n            type=\"search\"\n            data-pn-search=\"\"\n            value={query}\n            placeholder={labels?.search ?? \"Search sounds\"}\n            aria-label={labels?.search ?? \"Search sounds\"}\n            aria-keyshortcuts=\"/\"\n            onChange={(event) => setQuery(event.currentTarget.value)}\n            onBlur={() => {\n              if (query === \"\") setSearchOpen(false)\n            }}\n            onKeyDown={(event) => {\n              if (event.key === \"Escape\" && query !== \"\") {\n                event.preventDefault()\n                event.stopPropagation()\n                setQuery(\"\")\n              } else if (event.key === \"Escape\" && searchOpen) {\n                event.preventDefault()\n                event.stopPropagation()\n                setSearchOpen(false)\n                requestAnimationFrame(() => searchButtonRef.current?.focus())\n              } else if (event.key === \"ArrowDown\") {\n                const first = bodyRef.current?.querySelector<HTMLElement>(\"[data-pn-item]\")\n                if (first) {\n                  event.preventDefault()\n                  first.focus()\n                }\n              }\n            }}\n          />\n          {/* Our own clear button: the browser's search \"×\" ignores the\n              face's colours (black on a dark sidebar). */}\n          {query === \"\" ? null : (\n            <button\n              type=\"button\"\n              data-pn-clear=\"\"\n              aria-label=\"Clear the search\"\n              onMouseDown={(event) => event.preventDefault()}\n              onClick={() => {\n                setQuery(\"\")\n                searchRef.current?.focus()\n              }}\n            >\n              {ICON.clear()}\n            </button>\n          )}\n        </label>\n        )}\n        {/* How the groups are drawn: only where groups are listed. */}\n        {!listsGroups ? null : (\n          <div data-pn-layout role=\"group\" aria-label=\"View as\">\n            <button\n              type=\"button\"\n              data-pn-icon=\"\"\n              aria-label=\"List\"\n              aria-pressed={layout === \"list\"}\n              onClick={() => setLayout(\"list\")}\n            >\n              {ICON.list()}\n            </button>\n            <button\n              type=\"button\"\n              data-pn-icon=\"\"\n              aria-label=\"Cards\"\n              aria-pressed={layout === \"cards\"}\n              onClick={() => setLayout(\"cards\")}\n            >\n              {ICON.cards()}\n            </button>\n          </div>\n        )}\n        {expand === undefined ? null : (\n          <button\n            type=\"button\"\n            data-pn-icon=\"\"\n            data-pn-expand=\"\"\n            aria-label={\n              expanded\n                ? (labels?.collapse ?? \"Back to the sidebar\")\n                : (labels?.expand ?? \"Open the full browser\")\n            }\n            aria-pressed={expanded}\n            onClick={expand}\n          >\n            {expanded ? ICON.collapse() : ICON.expand()}\n          </button>\n        )}\n      </div>\n\n      <div ref={bodyRef} data-pn-body=\"\">\n        {columns === 1 ? (\n          <div\n            data-pn-panel=\"\"\n            role=\"region\"\n            aria-label={\n              searching\n                ? \"Search results\"\n                : showing === \"groups\"\n                  ? groupsLabel\n                  : (openGroup?.label ?? groupsLabel)\n            }\n            onKeyDown={(event) =>\n              moveNavigatorFocus(event, !searching && showing === \"groups\" && layout === \"cards\")\n            }\n          >\n            {searching ? (\n              searchResults\n            ) : showing === \"groups\" ? (\n              <>\n                <h3 data-pn-section-label>{groupsLabel}</h3>\n                {groupsList}\n              </>\n            ) : (\n              <>\n                {/* The way back, on the group's picture. No name: the face's\n                    header above already says what is open. */}\n                <div data-pn-banner=\"\" data-pn-has-art={layout === \"cards\" ? \"\" : undefined}>\n                  {layout === \"cards\" && openGroup !== null ? <GroupArt group={openGroup} /> : null}\n                  <button type=\"button\" data-pn-back=\"\" onClick={() => setTab(\"groups\")}>\n                    {ICON.back()}\n                    {labels?.back ?? groupsLabel}\n                  </button>\n                </div>\n                <div data-pn-presets>{presets.map((entry) => presetItem(entry))}</div>\n              </>\n            )}\n          </div>\n        ) : searching ? (\n          <div\n            data-pn-panel=\"\"\n            data-pn-column=\"results\"\n            role=\"region\"\n            aria-label=\"Search results\"\n            onKeyDown={(event) => moveNavigatorFocus(event, false)}\n          >\n            {searchResults}\n          </div>\n        ) : (\n          <div ref={pairRef} data-pn-pair=\"\">\n            <div\n              id={pairId}\n              data-pn-panel=\"\"\n              data-pn-column=\"groups\"\n              role=\"region\"\n              aria-label={groupsLabel}\n              style={{ flexGrow: split, flexBasis: 0 }}\n              onKeyDown={(event) => moveNavigatorFocus(event, layout === \"cards\")}\n            >\n              <h3 data-pn-section-label>{groupsLabel}</h3>\n              {groupsList}\n            </div>\n            {pairSpan <= 2 * COLUMN_MIN ? null : (\n              <ResizeHandle\n                orientation=\"vertical\"\n                direction={1}\n                value={split * pairSpan}\n                min={COLUMN_MIN}\n                max={pairSpan - COLUMN_MIN}\n                onValueChange={(px) => setSplit(px / pairSpan)}\n                onValueCommitted={(px) => commitSplit(px / pairSpan)}\n                /* Double-click or Enter: back to half and half. */\n                onCollapsedChange={() => commitSplit(0.5)}\n                aria-label={`${groupsLabel} width`}\n                aria-controls={pairId}\n                style={{ margin: `0 -${DIVIDER / 2}px`, zIndex: 1 }}\n              />\n            )}\n            <div\n              data-pn-panel=\"\"\n              data-pn-column=\"presets\"\n              role=\"region\"\n              aria-label={openGroup?.label ?? presetsLabel}\n              style={{ flexGrow: 1 - split, flexBasis: 0 }}\n              onKeyDown={(event) => moveNavigatorFocus(event, false)}\n            >\n              <h3 data-pn-section-label>{presetsLabel}</h3>\n              {presets.length === 0 ? (\n                <p data-pn-empty>\n                  {openGroup === null ? `Choose from ${groupsLabel}.` : \"Nothing here with these tags.\"}\n                </p>\n              ) : (\n                <div data-pn-presets>{presets.map((entry) => presetItem(entry))}</div>\n              )}\n            </div>\n          </div>\n        )}\n        {tagCategory === null ? null : (\n          <aside data-pn-panel=\"\" data-pn-column=\"tags\" aria-label={tagsLabel}>\n            <TagBrowser\n              categories={[tagCategory]}\n              value={{ [PRESET_LIBRARY_TAG_CATEGORY]: [...pickedTags] }}\n              onChange={(next) => setPickedTags(next[PRESET_LIBRARY_TAG_CATEGORY] ?? NO_TAGS)}\n              layout=\"stacked\"\n              disableZeroCount\n              showCounts={false}\n              label={tagsLabel}\n              variant=\"quiet\"\n            />\n          </aside>\n        )}\n      </div>\n    </nav>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/preset-library-navigator.tsx"
    },
    {
      "path": "components/ui/preset-library-navigator-parts.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/preset-browser\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * The pieces `PresetLibraryNavigator` lists with: a group's picture, its\n * icons, the group and preset items, and arrow-key focus within a column.\n * Stateless; the navigator owns what is open, searched and selected.\n */\n\nimport type { KeyboardEvent, SyntheticEvent } from \"react\"\nimport type { PresetLibraryEntry } from \"maruaudio/ui/shared/maru/contracts/preset-library\"\nimport type { PresetLibraryNavigatorGroup } from \"maruaudio/ui/shared/preset-library-navigator-model\"\n\nexport type PresetNavigatorLayout = \"list\" | \"cards\"\n\n/** A group's placeholder picture: its accent, else a hue from its id, as a\n *  soft diagonal gradient. Stable for a given group. */\nexport function presetGroupPlaceholder(id: string, accent?: string): string {\n  if (accent !== undefined)\n    return `linear-gradient(135deg, ${accent}, color-mix(in oklab, ${accent} 25%, #05070a))`\n  let hash = 0\n  for (let index = 0; index < id.length; index += 1)\n    hash = (hash * 31 + id.charCodeAt(index)) >>> 0\n  const hue = hash % 360\n  return `linear-gradient(135deg, hsl(${hue} 42% 34%), hsl(${(hue + 48) % 360} 48% 12%))`\n}\n\n/** Hide a cover that failed to load: the placeholder under it shows. */\nfunction hideBroken(event: SyntheticEvent<HTMLImageElement>) {\n  event.currentTarget.hidden = true\n}\n\nexport function GroupArt({ group }: { group: PresetLibraryNavigatorGroup }) {\n  return (\n    <span\n      data-pn-art=\"\"\n      aria-hidden=\"true\"\n      style={{ background: presetGroupPlaceholder(group.id, group.artwork?.accent) }}\n    >\n      {group.artwork?.cover === undefined ? null : (\n        <img src={group.artwork.cover} alt=\"\" loading=\"lazy\" decoding=\"async\" onError={hideBroken} />\n      )}\n    </span>\n  )\n}\n\n/* Functions, not module-level elements: nothing renders at import, so a\n * tool that only imports the face (module:dev's catalog reader) never needs\n * React in scope. */\nexport const NAVIGATOR_ICON = {\n  list: () => (\n    <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"M2.5 4h11M2.5 8h11M2.5 12h11\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  ),\n  cards: () => (\n    <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" aria-hidden=\"true\" focusable=\"false\">\n      <rect x=\"2\" y=\"2\" width=\"5\" height=\"5\" rx=\"1\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" />\n      <rect x=\"9\" y=\"2\" width=\"5\" height=\"5\" rx=\"1\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" />\n      <rect x=\"2\" y=\"9\" width=\"5\" height=\"5\" rx=\"1\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" />\n      <rect x=\"9\" y=\"9\" width=\"5\" height=\"5\" rx=\"1\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" />\n    </svg>\n  ),\n  expand: () => (\n    <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" aria-hidden=\"true\" focusable=\"false\">\n      <path\n        d=\"M9.5 2.5h4v4M13.5 2.5 9 7M6.5 13.5h-4v-4M2.5 13.5 7 9\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  ),\n  collapse: () => (\n    <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" aria-hidden=\"true\" focusable=\"false\">\n      <path\n        d=\"M13.5 6.5h-4v-4M9.5 6.5 14 2M2.5 9.5h4v4M6.5 9.5 2 14\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  ),\n  back: () => (\n    <svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"m9.5 3.5-4.5 4.5 4.5 4.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  ),\n  clear: () => (\n    <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"m4 4 8 8M12 4l-8 8\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />\n    </svg>\n  ),\n  search: () => (\n    <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" aria-hidden=\"true\" focusable=\"false\">\n      <circle cx=\"7\" cy=\"7\" r=\"4.25\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"m10.25 10.25 3.25 3.25\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  ),\n} as const\n\n/** Arrow keys between a column's items, in reading order. In the cards\n *  grid, up/down move a row: the column count is read from the layout. */\nexport function moveNavigatorFocus(event: KeyboardEvent<HTMLElement>, grid: boolean) {\n  const keys = [\"ArrowDown\", \"ArrowUp\", \"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"]\n  if (!keys.includes(event.key)) return\n  const items = [...event.currentTarget.querySelectorAll<HTMLElement>(\"[data-pn-item]\")]\n  const index = items.indexOf(event.target as HTMLElement)\n  if (index < 0 || items.length === 0) return\n  let columns = 1\n  if (grid) {\n    const top = items[0]!.offsetTop\n    const wrap = items.findIndex((item) => item.offsetTop !== top)\n    columns = wrap <= 0 ? items.length : wrap\n  }\n  let next = index\n  if (event.key === \"Home\") next = 0\n  else if (event.key === \"End\") next = items.length - 1\n  else if (event.key === \"ArrowDown\") next = index + columns\n  else if (event.key === \"ArrowUp\") next = index - columns\n  else if (event.key === \"ArrowRight\") next = grid ? index + 1 : index\n  else if (event.key === \"ArrowLeft\") next = grid ? index - 1 : index\n  next = Math.min(items.length - 1, Math.max(0, next))\n  if (next === index && event.key !== \"Home\" && event.key !== \"End\") return\n  event.preventDefault()\n  items[next]!.focus()\n}\n\n/** A group as a list row or a picture card. */\nexport function GroupItem({\n  group,\n  view,\n  open,\n  onActivate,\n}: {\n  group: PresetLibraryNavigatorGroup\n  view: PresetNavigatorLayout\n  /** Side by side: the group whose presets the next column shows. */\n  open?: boolean\n  onActivate: (groupId: string) => void\n}) {\n  return (\n    <button\n      type=\"button\"\n      data-pn-item=\"\"\n      data-pn-group={view}\n      data-group-id={group.id}\n      data-pn-open={open ? \"\" : undefined}\n      aria-current={group.current ? \"true\" : undefined}\n      onClick={() => onActivate(group.id)}\n    >\n      <GroupArt group={group} />\n      <span data-pn-group-name>{group.label}</span>\n    </button>\n  )\n}\n\n/** A preset row: its name, the modified marker on the active one, and its\n *  secondary label unless it repeats the name or the group. */\nexport function PresetItem({\n  entry,\n  active,\n  dirty,\n  showGroup = false,\n  onChoose,\n}: {\n  entry: PresetLibraryEntry\n  active: boolean\n  dirty: boolean\n  /** Listed under its group's heading: the secondary label is not needed. */\n  showGroup?: boolean\n  onChoose: (entry: PresetLibraryEntry) => void\n}) {\n  return (\n    <button\n      type=\"button\"\n      data-pn-item=\"\"\n      data-pn-preset=\"\"\n      data-entry-id={entry.id}\n      aria-current={active ? \"true\" : undefined}\n      onClick={() => onChoose(entry)}\n    >\n      <span data-pn-preset-name>\n        {entry.name}\n        {active && dirty ? <span data-pn-dirty aria-label=\"modified\"> *</span> : null}\n      </span>\n      {showGroup ||\n      entry.secondary === undefined ||\n      entry.secondary === entry.name ||\n      entry.secondary === entry.group.label ? null : (\n        <span data-pn-secondary>{entry.secondary}</span>\n      )}\n    </button>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/preset-library-navigator-parts.tsx"
    },
    {
      "path": "components/ui/preset-save-dialog.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/preset-browser\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * PresetSaveDialog — name the current sound and tag it from the library's\n * fixed vocabulary, then ask the host to save it.\n *\n * The name starts as the active entry's name (or \"Untitled\"); the tags start\n * as the active entry's vocabulary tags. Tags are chips from\n * `library.tagVocabulary` only (no free-form tags in v1), at most `maxTags`\n * (8) — a ninth pick is refused and announced by the reused `TagBrowser`.\n * When the active entry is the viewer's own and the host can overwrite,\n * \"Overwrite\" and \"Save as new\" are offered (Overwrite preselected).\n *\n * Save (the button, or Enter in the name field or on a mode radio) sends one\n * `library.save` request and waits: the dialog is pending and cannot be\n * dismissed, an `{ ok: false, error }` answer is shown inline, `{ ok: true }`\n * closes the dialog, and so does `{ ok: false, dismiss: true }` — the host\n * took the request over with its own UI (the website's sign-in interstitial\n * for a signed-out save), which must not open on top of this dialog. The new\n * entry arrives as fresh library props from the host; the dialog never writes\n * anything itself.\n *\n * There is deliberately no `<form>`: packaged faces run in an iframe sandboxed\n * with `allow-scripts` only (no `allow-forms`), where the browser never fires\n * `submit`. Saving is a button click and an Enter keydown, nothing else.\n *\n * It renders inside the face (see `preset-overlay-container`), centred over a\n * dimmed backdrop, built on Base UI's Dialog for focus trapping, Escape and\n * focus return. Nested inside an open `PresetBrowser`, it stacks above it.\n */\n\nimport {\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\"\nimport { Dialog } from \"@base-ui/react/dialog\"\nimport type { AudioModuleFacePresetLibrary } from \"maruaudio/ui/shared/maru/contracts/preset-library\"\nimport { TagBrowser } from \"@/components/ui/tag-browser\"\nimport type { TagSelection } from \"maruaudio/ui/shared/tag-browser-model\"\nimport {\n  PRESET_SAVE_MAX_TAGS,\n  presetSaveDefaults,\n  presetSaveRequest,\n  requestPresetLibrarySave,\n  type PresetSaveFormError,\n  type PresetSaveMode,\n} from \"maruaudio/ui/shared/preset-library-model\"\nimport { usePresetOverlayTarget } from \"maruaudio/ui/shared/preset-overlay-container\"\nimport {\n  PRESET_BROWSER_STYLES,\n  type PresetBrowserVariant,\n} from \"maruaudio/ui/shared/preset-browser-styles\"\n\nexport interface PresetSaveDialogProps {\n  readonly library: AudioModuleFacePresetLibrary\n  readonly open: boolean\n  readonly onOpenChange: (open: boolean) => void\n  /** Called with the host's id after a successful save, before closing. */\n  readonly onSaved?: (id: string) => void\n  /** Render into this element instead of the nearest face container. */\n  readonly container?: HTMLElement | null\n  /** Most tags a preset may carry. Defaults to 8. */\n  readonly maxTags?: number\n  readonly variant?: PresetBrowserVariant\n  readonly title?: ReactNode\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\nconst FORM_ERRORS: Record<PresetSaveFormError, (maxTags: number) => string> = {\n  name: () => \"Name this preset.\",\n  tags: (maxTags) => `Pick up to ${maxTags} tags.`,\n  unavailable: () => \"Saving isn’t available here.\",\n}\n\nconst TAGS = \"tags\"\n\nexport function PresetSaveDialog({\n  library,\n  open,\n  onOpenChange,\n  onSaved,\n  container: explicitContainer,\n  maxTags = PRESET_SAVE_MAX_TAGS,\n  variant = \"panel\",\n  title = \"Save preset\",\n  className,\n  style,\n}: PresetSaveDialogProps) {\n  const { anchorRef, container, contained } = usePresetOverlayTarget(\n    open,\n    explicitContainer\n  )\n  const [name, setName] = useState(\"\")\n  const [tags, setTags] = useState<readonly string[]>([])\n  const [mode, setMode] = useState<PresetSaveMode>(\"new\")\n  const [overwriteId, setOverwriteId] = useState<string | null>(null)\n  const [canOverwrite, setCanOverwrite] = useState(false)\n  const [overwriteName, setOverwriteName] = useState(\"\")\n  const [pending, setPending] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const nameRef = useRef<HTMLInputElement>(null)\n  const latest = useRef(library)\n  latest.current = library\n  // A result that arrives after the dialog closed (or reopened) is ignored.\n  const generation = useRef(0)\n  const baseId = useId()\n\n  useLayoutEffect(() => {\n    generation.current += 1\n    if (!open) {\n      setPending(false)\n      return\n    }\n    const defaults = presetSaveDefaults(latest.current, { maxTags })\n    setName(defaults.name)\n    setTags(defaults.tags)\n    setMode(defaults.mode)\n    setOverwriteId(defaults.overwriteId)\n    setCanOverwrite(defaults.canOverwrite)\n    setOverwriteName(\n      latest.current.entries.find((entry) => entry.id === defaults.overwriteId)\n        ?.name ?? \"\"\n    )\n    setError(null)\n    setPending(false)\n  }, [maxTags, open])\n\n  const submit = async () => {\n    if (pending) return\n    const built = presetSaveRequest(\n      latest.current,\n      { name, tags, mode, overwriteId },\n      { maxTags }\n    )\n    if (!built.ok) {\n      setError(FORM_ERRORS[built.error](maxTags))\n      if (built.error === \"name\") nameRef.current?.focus()\n      return\n    }\n    const ticket = generation.current\n    setPending(true)\n    setError(null)\n    const result = await requestPresetLibrarySave(latest.current, built.request)\n    if (ticket !== generation.current) return\n    if (result.ok) {\n      setPending(false)\n      onSaved?.(result.id)\n      onOpenChange(false)\n      return\n    }\n    setPending(false)\n    if (result.dismiss === true) {\n      onOpenChange(false)\n      return\n    }\n    setError(result.error)\n  }\n\n  /** Enter saves from the name field and the mode radios, as a form would. */\n  const submitOnEnter = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key !== \"Enter\" || event.nativeEvent.isComposing) return\n    if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) return\n    event.preventDefault()\n    void submit()\n  }\n\n  const vocabulary = library.tagVocabulary\n  const tagValue: TagSelection = tags.length > 0 ? { [TAGS]: tags } : {}\n  const position = contained ? \"absolute\" : \"fixed\"\n  const submitLabel = pending\n    ? \"Saving…\"\n    : mode === \"overwrite\" && canOverwrite\n      ? \"Overwrite\"\n      : \"Save\"\n\n  return (\n    <>\n      <span ref={anchorRef} hidden data-preset-save-anchor=\"\" />\n      <Dialog.Root\n        open={open}\n        modal=\"trap-focus\"\n        onOpenChange={(next) => {\n          // A pending save cannot be dismissed: its answer must land here.\n          if (!next && pending) return\n          onOpenChange(next)\n        }}\n      >\n        {open && container ? (\n          <Dialog.Portal container={container}>\n            <Dialog.Backdrop\n              forceRender\n              data-preset-save-backdrop=\"\"\n              style={{ position }}\n            />\n            <Dialog.Popup\n              data-preset-save-dialog=\"\"\n              data-variant={variant}\n              data-pending={pending || undefined}\n              className={className}\n              initialFocus={nameRef}\n              style={{ position, inset: 0, height: \"fit-content\", ...style }}\n            >\n              <style data-preset-browser-styles=\"\">\n                {PRESET_BROWSER_STYLES}\n              </style>\n              <div data-preset-save-form=\"\" aria-busy={pending || undefined}>\n                <Dialog.Title data-preset-save-title=\"\">{title}</Dialog.Title>\n                <label data-preset-save-field=\"\" htmlFor={`${baseId}-name`}>\n                  <span data-preset-save-label=\"\">Name</span>\n                  <input\n                    ref={nameRef}\n                    id={`${baseId}-name`}\n                    data-preset-save-name=\"\"\n                    type=\"text\"\n                    value={name}\n                    maxLength={64}\n                    autoComplete=\"off\"\n                    spellCheck={false}\n                    disabled={pending}\n                    aria-invalid={error !== null && name.trim() === \"\"}\n                    onChange={(event) => {\n                      setName(event.target.value)\n                      if (error) setError(null)\n                    }}\n                    onKeyDown={submitOnEnter}\n                  />\n                </label>\n                {canOverwrite ? (\n                  <fieldset data-preset-save-modes=\"\" disabled={pending}>\n                    <legend data-pb-hidden=\"\">Save mode</legend>\n                    <label>\n                      <input\n                        type=\"radio\"\n                        name={`${baseId}-mode`}\n                        value=\"overwrite\"\n                        checked={mode === \"overwrite\"}\n                        onChange={() => setMode(\"overwrite\")}\n                        onKeyDown={submitOnEnter}\n                        data-preset-save-mode=\"overwrite\"\n                      />\n                      {`Overwrite “${overwriteName}”`}\n                    </label>\n                    <label>\n                      <input\n                        type=\"radio\"\n                        name={`${baseId}-mode`}\n                        value=\"new\"\n                        checked={mode === \"new\"}\n                        onChange={() => setMode(\"new\")}\n                        onKeyDown={submitOnEnter}\n                        data-preset-save-mode=\"new\"\n                      />\n                      Save as new\n                    </label>\n                  </fieldset>\n                ) : null}\n                {vocabulary.length > 0 ? (\n                  <div data-preset-save-field=\"\">\n                    <span data-preset-save-label=\"\" aria-hidden=\"true\">\n                      Tags\n                    </span>\n                    <TagBrowser\n                      categories={[\n                        {\n                          id: TAGS,\n                          label: \"Tags\",\n                          selection: { max: maxTags },\n                          tags: vocabulary.map((tag) => ({\n                            id: tag.id,\n                            label: tag.label,\n                          })),\n                        },\n                      ]}\n                      value={tagValue}\n                      onChange={(next) => {\n                        setTags(next[TAGS] ?? [])\n                        if (error) setError(null)\n                      }}\n                      layout=\"stacked\"\n                      showCounts={false}\n                      label=\"Preset tags\"\n                      disabled={pending}\n                    />\n                  </div>\n                ) : null}\n                {error ? (\n                  <p role=\"alert\" data-preset-save-error=\"\">\n                    {error}\n                  </p>\n                ) : null}\n                <div data-preset-save-actions=\"\">\n                  <Dialog.Close\n                    type=\"button\"\n                    data-pb-button=\"\"\n                    disabled={pending}\n                    data-preset-save-cancel=\"\"\n                  >\n                    Cancel\n                  </Dialog.Close>\n                  <button\n                    type=\"button\"\n                    data-pb-button=\"\"\n                    data-primary=\"\"\n                    data-preset-save-submit=\"\"\n                    disabled={pending || name.trim() === \"\"}\n                    onClick={() => void submit()}\n                  >\n                    {submitLabel}\n                  </button>\n                </div>\n              </div>\n            </Dialog.Popup>\n          </Dialog.Portal>\n        ) : null}\n      </Dialog.Root>\n    </>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/preset-save-dialog.tsx"
    }
  ]
}
