{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-take-switcher",
  "type": "registry:ui",
  "title": "Take Switcher",
  "description": "Generated variations as controlled tiles (A, B, C…) with a mini waveform, a badge slot, progress while generating, an error with retry, and pin and regenerate buttons whose refusals disable with a reason. A radiogroup with one tab stop: arrows, [ and ] (inside the switcher or window-wide, never in text fields) and previous/next buttons cycle selectable takes. Tiles wrap to the width. The host owns generation, pinning and what selecting does.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/take-switcher.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/take-switcher\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 * TakeSwitcher — the generated variations of a sound as tiles (A, B, C…),\n * each with a mini waveform, a badge slot (a detected pitch, say), progress\n * while it generates, and pin and regenerate buttons.\n *\n * Selection is controlled: `value` is the selected take id and\n * `onValueChange` asks for another. The tiles are a `radiogroup`: one tab\n * stop, arrow keys move the selection, `[` and `]` cycle it (while focus is\n * inside the switcher, or anywhere with `shortcuts=\"window\"`, except in text\n * fields), and the header's previous/next buttons do the same. Only ready,\n * enabled takes can be selected; a generating tile shows progress and an\n * errored tile its error, with regenerate as retry.\n *\n * The component generates, fetches and plays nothing. The host owns takes,\n * pinning and regeneration, and decides what selecting a take does\n * (audition, install).\n */\n\nimport {\n  useEffect,\n  useRef,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  cycleTake,\n  isEditableKeyTarget,\n  isTakeSelectable,\n  takeLabel,\n  takeShortcutDirection,\n  takeStatusText,\n  takeWaveformPath,\n  type TakeSwitcherTake,\n} from \"maruaudio/ui/shared/take-switcher-model\"\n\nexport interface TakeSwitcherRefusals {\n  /** Why take `id` cannot be regenerated right now, or undefined. */\n  readonly regenerate?: (id: string) => string | undefined\n  /** Why take `id` cannot be pinned or unpinned right now, or undefined. */\n  readonly pin?: (id: string) => string | undefined\n}\n\nexport interface TakeSwitcherProps {\n  readonly takes: readonly TakeSwitcherTake[]\n  /** Selected take id. */\n  readonly value: string | null\n  readonly onValueChange: (id: string) => void\n  /** Without it there are no pin buttons. */\n  readonly onPinChange?: (id: string, pinned: boolean) => void\n  /** Without it there are no regenerate buttons. */\n  readonly onRegenerate?: (id: string) => void\n  /** The badge slot, under the waveform. */\n  readonly renderBadge?: (take: TakeSwitcherTake, index: number) => ReactNode\n  /** A reason disables that action and becomes its tooltip. */\n  readonly refusals?: TakeSwitcherRefusals\n  /** Where `[`/`]` work: inside the switcher (default), the whole window, or nowhere. */\n  readonly shortcuts?: \"focus\" | \"window\" | \"none\"\n  /** Show the previous/next buttons. Defaults to true. */\n  readonly showStepButtons?: boolean\n  /** Header content beside the step buttons, such as \"Regenerate all\". */\n  readonly actions?: ReactNode\n  /** Accessible name and header text. Defaults to \"Takes\". */\n  readonly label?: string\n  /** Hide the header label visually (it still names the group). */\n  readonly hideLabel?: boolean\n  /** Smallest tile width in px; tiles wrap. Defaults to 72. */\n  readonly tileMinWidth?: number\n  readonly disabled?: boolean\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\nconst VISUALLY_HIDDEN: CSSProperties = {\n  position: \"absolute\",\n  width: 1,\n  height: 1,\n  margin: -1,\n  padding: 0,\n  overflow: \"hidden\",\n  clip: \"rect(0 0 0 0)\",\n  whiteSpace: \"nowrap\",\n  border: 0,\n}\n\nconst iconButton: CSSProperties = {\n  display: \"inline-grid\",\n  placeItems: \"center\",\n  width: 24,\n  height: 24,\n  flex: \"none\",\n  border: 0,\n  borderRadius: 6,\n  background: \"transparent\",\n  color: \"inherit\",\n  padding: 0,\n  cursor: \"pointer\",\n}\n\nfunction PinIcon({ filled }: { readonly filled: boolean }) {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" aria-hidden=\"true\" fill=\"none\">\n      <path\n        d=\"M6 2h4l-.5 4 2.5 2.5v1H9v4.5L8 15l-1-1V9.5H4v-1L6.5 6 6 2Z\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.4\"\n        strokeLinejoin=\"round\"\n        fill={filled ? \"currentColor\" : \"none\"}\n      />\n    </svg>\n  )\n}\n\nfunction RegenerateIcon() {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" aria-hidden=\"true\" fill=\"none\">\n      <path\n        d=\"M13 8a5 5 0 1 1-1.6-3.7M13 2.5v3h-3\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n\nfunction ChevronIcon({ direction }: { readonly direction: -1 | 1 }) {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" aria-hidden=\"true\" fill=\"none\">\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=\"1.8\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n\nexport function TakeSwitcher({\n  takes,\n  value,\n  onValueChange,\n  onPinChange,\n  onRegenerate,\n  renderBadge,\n  refusals,\n  shortcuts = \"focus\",\n  showStepButtons = true,\n  actions,\n  label = \"Takes\",\n  hideLabel = false,\n  tileMinWidth = 72,\n  disabled = false,\n  className,\n  style,\n}: TakeSwitcherProps) {\n  const radios = useRef(new Map<string, HTMLButtonElement>())\n  const latest = useRef({ takes, value, onValueChange, disabled })\n  latest.current = { takes, value, onValueChange, disabled }\n\n  const select = (id: string | null, focus = false) => {\n    if (!id || latest.current.disabled) return\n    if (id !== latest.current.value) latest.current.onValueChange(id)\n    if (focus) radios.current.get(id)?.focus()\n  }\n  const step = (direction: 1 | -1, focus = false) =>\n    select(cycleTake(latest.current.takes, latest.current.value, direction), focus)\n\n  useEffect(() => {\n    if (shortcuts !== \"window\" || typeof window === \"undefined\") return\n    const onKey = (event: globalThis.KeyboardEvent) => {\n      const direction = takeShortcutDirection(event.key)\n      if (\n        direction === 0 ||\n        event.defaultPrevented ||\n        event.isComposing ||\n        event.ctrlKey ||\n        event.metaKey ||\n        event.altKey ||\n        isEditableKeyTarget(event.target)\n      )\n        return\n      event.preventDefault()\n      step(direction)\n    }\n    document.addEventListener(\"keydown\", onKey)\n    return () => document.removeEventListener(\"keydown\", onKey)\n    // `step` reads everything it needs from `latest`.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [shortcuts])\n\n  const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (shortcuts !== \"focus\") return\n    const direction = takeShortcutDirection(event.key)\n    if (direction === 0 || event.ctrlKey || event.metaKey || event.altKey) return\n    if (isEditableKeyTarget(event.target)) return\n    event.preventDefault()\n    // Keep focus on the selected radio when it already has it.\n    const onRadio = (event.target as HTMLElement | null)?.getAttribute?.(\"role\") === \"radio\"\n    step(direction, onRadio)\n  }\n\n  const onRadioKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {\n    let direction: 1 | -1 | 0 = 0\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") direction = 1\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") direction = -1\n    if (direction !== 0) {\n      event.preventDefault()\n      select(cycleTake(takes, id, direction), true)\n      return\n    }\n    if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault()\n      const pool = event.key === \"Home\" ? takes : [...takes].reverse()\n      select(pool.find(isTakeSelectable)?.id ?? null, true)\n    }\n  }\n\n  const selectedPresent = takes.some((take) => take.id === value && isTakeSelectable(take))\n  const tabStop = selectedPresent ? value : (takes.find(isTakeSelectable)?.id ?? null)\n  const canStep = !disabled && cycleTake(takes, value, 1) !== null\n\n  return (\n    <div\n      data-take-switcher=\"\"\n      className={className}\n      onKeyDown={onRootKeyDown}\n      style={{\n        display: \"flex\",\n        flexDirection: \"column\",\n        gap: 6,\n        minWidth: 0,\n        fontFamily: token.font,\n        color: token.text,\n        ...style,\n      }}\n    >\n      <div\n        data-take-switcher-header=\"\"\n        style={{ display: \"flex\", alignItems: \"center\", gap: 4, minWidth: 0 }}\n      >\n        <span\n          style={\n            hideLabel\n              ? VISUALLY_HIDDEN\n              : {\n                  fontSize: 11,\n                  fontWeight: 650,\n                  letterSpacing: \"0.1em\",\n                  textTransform: \"uppercase\",\n                  color: token.textMuted,\n                  marginRight: \"auto\",\n                }\n          }\n        >\n          {label}\n        </span>\n        {hideLabel ? <span style={{ marginRight: \"auto\" }} /> : null}\n        {actions}\n        {showStepButtons ? (\n          <>\n            <button\n              type=\"button\"\n              aria-label=\"Previous take\"\n              data-take-step=\"-1\"\n              className=\"uikit-control\"\n              disabled={!canStep}\n              onClick={() => step(-1)}\n              style={{ ...iconButton, border: `1px solid ${token.border}`, opacity: canStep ? 1 : 0.4 }}\n            >\n              <ChevronIcon direction={-1} />\n            </button>\n            <button\n              type=\"button\"\n              aria-label=\"Next take\"\n              data-take-step=\"1\"\n              className=\"uikit-control\"\n              disabled={!canStep}\n              onClick={() => step(1)}\n              style={{ ...iconButton, border: `1px solid ${token.border}`, opacity: canStep ? 1 : 0.4 }}\n            >\n              <ChevronIcon direction={1} />\n            </button>\n          </>\n        ) : null}\n      </div>\n\n      <div\n        role=\"radiogroup\"\n        aria-label={label}\n        aria-disabled={disabled || undefined}\n        data-take-tiles=\"\"\n        style={{\n          display: \"grid\",\n          gridTemplateColumns: `repeat(auto-fill, minmax(min(${tileMinWidth}px, 100%), 1fr))`,\n          gap: 6,\n          minWidth: 0,\n        }}\n      >\n        {takes.map((take, index) => {\n          const name = takeLabel(take, index)\n          const selected = take.id === value\n          const selectable = isTakeSelectable(take)\n          const status = take.status ?? \"ready\"\n          const statusText = takeStatusText(take)\n          const regenerateRefusal = refusals?.regenerate?.(take.id)\n          const pinRefusal = refusals?.pin?.(take.id)\n          const progress =\n            take.progress !== undefined ? Math.max(0, Math.min(1, take.progress)) : undefined\n          return (\n            <div\n              key={take.id}\n              data-take-tile={take.id}\n              data-selected={selected || undefined}\n              data-status={status}\n              data-pinned={take.pinned || undefined}\n              style={{\n                position: \"relative\",\n                display: \"flex\",\n                flexDirection: \"column\",\n                minWidth: 0,\n                border: `1px solid ${selected ? token.accent : token.border}`,\n                borderRadius: token.radius,\n                background: selected\n                  ? `color-mix(in oklab, ${token.accent} 14%, ${token.surface})`\n                  : token.surface,\n                overflow: \"hidden\",\n              }}\n            >\n              <button\n                ref={(node) => {\n                  if (node) radios.current.set(take.id, node)\n                  else radios.current.delete(take.id)\n                }}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={selected}\n                aria-disabled={disabled || !selectable || undefined}\n                tabIndex={take.id === tabStop && !disabled ? 0 : -1}\n                data-take-radio={take.id}\n                className=\"uikit-control\"\n                onClick={() => {\n                  if (selectable) select(take.id)\n                }}\n                onKeyDown={(event) => onRadioKeyDown(event, take.id)}\n                style={{\n                  display: \"flex\",\n                  flexDirection: \"column\",\n                  alignItems: \"stretch\",\n                  gap: 3,\n                  minWidth: 0,\n                  border: 0,\n                  background: \"transparent\",\n                  color: \"inherit\",\n                  padding: \"6px 6px 2px\",\n                  textAlign: \"left\",\n                  font: \"inherit\",\n                  cursor: selectable && !disabled ? \"pointer\" : \"default\",\n                  opacity: selectable || status === \"generating\" ? 1 : 0.6,\n                }}\n              >\n                <span\n                  style={{\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    gap: 4,\n                    fontSize: 12,\n                    fontWeight: 700,\n                    lineHeight: \"14px\",\n                  }}\n                >\n                  {name}\n                  {take.pinned ? (\n                    <span aria-hidden=\"true\" style={{ color: token.accent, display: \"inline-flex\" }}>\n                      <PinIcon filled />\n                    </span>\n                  ) : null}\n                </span>\n                <svg\n                  aria-hidden=\"true\"\n                  viewBox=\"0 0 100 100\"\n                  preserveAspectRatio=\"none\"\n                  data-take-waveform=\"\"\n                  style={{\n                    display: \"block\",\n                    width: \"100%\",\n                    height: 26,\n                    color: selected ? token.accent : token.textMuted,\n                    opacity: status === \"ready\" ? 1 : 0.35,\n                  }}\n                >\n                  <path d={takeWaveformPath(take.peaks)} fill=\"currentColor\" />\n                </svg>\n                <span\n                  data-take-badge=\"\"\n                  style={{\n                    minHeight: 14,\n                    overflow: \"hidden\",\n                    textOverflow: \"ellipsis\",\n                    whiteSpace: \"nowrap\",\n                    fontFamily: token.fontMono,\n                    fontSize: 10,\n                    lineHeight: \"14px\",\n                    color: token.textMuted,\n                  }}\n                >\n                  {status === \"error\"\n                    ? (take.error ?? \"Failed\")\n                    : status === \"generating\" || status === \"queued\"\n                      ? statusText\n                      : renderBadge?.(take, index)}\n                </span>\n                {statusText && status === \"ready\" ? (\n                  <span style={VISUALLY_HIDDEN}>{`, ${statusText}`}</span>\n                ) : null}\n              </button>\n              {status === \"generating\" ? (\n                <div\n                  role=\"progressbar\"\n                  aria-label={`${name} progress`}\n                  aria-valuemin={0}\n                  aria-valuemax={1}\n                  aria-valuenow={progress}\n                  data-take-progress=\"\"\n                  style={{ height: 2, margin: \"0 6px\", background: token.track, overflow: \"hidden\" }}\n                >\n                  <div\n                    style={{\n                      height: \"100%\",\n                      width: `${(progress ?? 0.3) * 100}%`,\n                      background: token.accent,\n                      transition: \"width 120ms linear\",\n                    }}\n                  />\n                </div>\n              ) : null}\n              {onPinChange || onRegenerate ? (\n                <div\n                  data-take-actions=\"\"\n                  style={{ display: \"flex\", justifyContent: \"flex-end\", gap: 2, padding: \"0 3px 3px\" }}\n                >\n                  {onPinChange ? (\n                    <button\n                      type=\"button\"\n                      aria-label={`${take.pinned ? \"Unpin\" : \"Pin\"} take ${name}`}\n                      aria-pressed={take.pinned === true}\n                      aria-description={pinRefusal}\n                      disabled={disabled || pinRefusal !== undefined}\n                      data-take-pin={take.id}\n                      className=\"uikit-control\"\n                      onClick={() => onPinChange(take.id, !take.pinned)}\n                      style={{\n                        ...iconButton,\n                        color: take.pinned ? token.accent : token.textMuted,\n                        opacity: disabled || pinRefusal ? 0.4 : 1,\n                      }}\n                    >\n                      <PinIcon filled={take.pinned === true} />\n                    </button>\n                  ) : null}\n                  {onRegenerate ? (\n                    <button\n                      type=\"button\"\n                      aria-label={`${status === \"error\" ? \"Retry\" : \"Regenerate\"} take ${name}`}\n                      aria-description={regenerateRefusal}\n                      disabled={disabled || regenerateRefusal !== undefined}\n                      data-take-regenerate={take.id}\n                      className=\"uikit-control\"\n                      onClick={() => onRegenerate(take.id)}\n                      style={{\n                        ...iconButton,\n                        color: token.textMuted,\n                        opacity: disabled || regenerateRefusal ? 0.4 : 1,\n                      }}\n                    >\n                      <RegenerateIcon />\n                    </button>\n                  ) : null}\n                </div>\n              ) : null}\n            </div>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/take-switcher.tsx"
    }
  ]
}
