{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tool-result","type":"registry:component","title":"Tool Result","description":"A lightweight execution disclosure for syntax-highlighted terminal output and request responses that collapses into a compact completed state.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","shiki","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/tool-result.tsx","type":"registry:component","target":"@components/agents/tool-result.tsx","content":"\"use client\";\n// beui.dev/components/agents/tool-result\n\nimport {\n  Ban,\n  Braces,\n  Check,\n  ChevronDown,\n  CircleCheck,\n  CircleX,\n  Copy,\n  LoaderCircle,\n  RotateCcw,\n  SquareTerminal,\n  Wrench,\n} from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  AgentCode,\n  type AgentCodeLanguage,\n} from \"@/components/agents/agent-code\";\nimport { ActionSwapRollText } from \"@/components/motion/action-swap-roll\";\nimport { AgentDisclosure } from \"@/components/agents/agent-disclosure\";\nimport { SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ToolResultStatus = \"running\" | \"success\" | \"error\" | \"cancelled\";\nexport type ToolResultKind = \"terminal\" | \"request\" | \"custom\";\n\nexport interface ToolResultProps {\n  tool: ReactNode;\n  title: ReactNode;\n  children: ReactNode;\n  status?: ToolResultStatus;\n  kind?: ToolResultKind;\n  meta?: ReactNode;\n  icon?: ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  collapseOnComplete?: boolean;\n  maxHeight?: number;\n  copyText?: string;\n  onCopy?: () => void | Promise<void>;\n  onRetry?: () => void;\n  className?: string;\n  contentClassName?: string;\n}\n\nexport interface ToolResultOutputProps {\n  children: string;\n  language?: AgentCodeLanguage;\n  className?: string;\n}\n\nfunction getStatusLabel(status: ToolResultStatus) {\n  if (status === \"running\") return \"Running\";\n  if (status === \"success\") return \"Completed\";\n  if (status === \"error\") return \"Failed\";\n  return \"Cancelled\";\n}\n\nfunction getSwapKey(value: ReactNode, fallback: string) {\n  return typeof value === \"string\" || typeof value === \"number\"\n    ? String(value)\n    : fallback;\n}\n\nfunction getStatusClass(status: ToolResultStatus) {\n  if (status === \"running\") {\n    return \"text-blue-600 dark:text-blue-400\";\n  }\n  if (status === \"success\") {\n    return \"text-emerald-600 dark:text-emerald-400\";\n  }\n  if (status === \"error\") {\n    return \"text-rose-600 dark:text-rose-400\";\n  }\n  return \"text-muted-foreground\";\n}\n\nfunction KindIcon({ kind }: { kind: ToolResultKind }) {\n  if (kind === \"terminal\") return <SquareTerminal className=\"size-4\" />;\n  if (kind === \"request\") return <Braces className=\"size-4\" />;\n  return <Wrench className=\"size-4\" />;\n}\n\nfunction StatusIcon({\n  status,\n  reduce,\n}: {\n  status: ToolResultStatus;\n  reduce: boolean;\n}) {\n  if (status === \"running\") {\n    return <LoaderCircle className={cn(\"size-3\", !reduce && \"animate-spin\")} />;\n  }\n  if (status === \"success\") return <CircleCheck className=\"size-3\" />;\n  if (status === \"error\") return <CircleX className=\"size-3\" />;\n  return <Ban className=\"size-3\" />;\n}\n\nfunction ToolResultAction({\n  label,\n  onClick,\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <motion.button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      onClick={onClick}\n      whileTap={reduce ? undefined : { scale: 0.9 }}\n      transition={SPRING_PRESS}\n      className=\"grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n    >\n      {children}\n    </motion.button>\n  );\n}\n\nexport function ToolResultOutput({\n  children,\n  language = \"bash\",\n  className,\n}: ToolResultOutputProps) {\n  return (\n    <AgentCode\n      code={children}\n      language={language}\n      className={cn(\n        \"whitespace-pre-wrap break-words text-foreground/80\",\n        className,\n      )}\n    />\n  );\n}\n\nexport function ToolResult({\n  tool,\n  title,\n  children,\n  status = \"running\",\n  kind = \"custom\",\n  meta,\n  icon,\n  open,\n  defaultOpen = true,\n  onOpenChange,\n  collapseOnComplete = true,\n  maxHeight = 220,\n  copyText,\n  onCopy,\n  onRetry,\n  className,\n  contentClassName,\n}: ToolResultProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const triggerId = `${baseId}-trigger`;\n  const contentId = `${baseId}-content`;\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const previousStatus = useRef(status);\n  const copyTimer = useRef<number | undefined>(undefined);\n  const [copied, setCopied] = useState(false);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const currentOpen = open ?? internalOpen;\n  const running = status === \"running\";\n  const canCopy = Boolean(copyText || onCopy);\n  const titleKey = getSwapKey(title, status);\n  const metaKey = getSwapKey(meta, `${status}-meta`);\n  const toolKey = getSwapKey(tool, `${status}-tool`);\n  const statusLabel = getStatusLabel(status);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (open === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [onOpenChange, open],\n  );\n\n  useEffect(() => {\n    if (previousStatus.current !== \"running\" && status === \"running\") {\n      setOpen(true);\n    }\n    if (\n      previousStatus.current === \"running\" &&\n      status !== \"running\" &&\n      collapseOnComplete\n    ) {\n      setOpen(false);\n    }\n    previousStatus.current = status;\n  }, [collapseOnComplete, setOpen, status]);\n\n  useEffect(\n    () => () => {\n      if (copyTimer.current) window.clearTimeout(copyTimer.current);\n    },\n    [],\n  );\n\n  useLayoutEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport || !currentOpen || !running) return;\n\n    const frame = requestAnimationFrame(() => {\n      if (typeof viewport.scrollTo === \"function\") {\n        viewport.scrollTo({\n          top: viewport.scrollHeight,\n          behavior: reduce ? \"auto\" : \"smooth\",\n        });\n      } else {\n        viewport.scrollTop = viewport.scrollHeight;\n      }\n    });\n    return () => cancelAnimationFrame(frame);\n  });\n\n  const handleCopy = useCallback(async () => {\n    if (onCopy) await onCopy();\n    else if (copyText) await navigator.clipboard?.writeText(copyText);\n\n    setCopied(true);\n    if (copyTimer.current) window.clearTimeout(copyTimer.current);\n    copyTimer.current = window.setTimeout(() => setCopied(false), 1600);\n  }, [copyText, onCopy]);\n\n  return (\n    <div\n      data-state={status}\n      aria-busy={running}\n      className={cn(\"w-full text-sm\", className)}\n    >\n      <button\n        id={triggerId}\n        type=\"button\"\n        aria-expanded={currentOpen}\n        aria-controls={contentId}\n        onClick={() => setOpen(!currentOpen)}\n        className=\"group flex min-h-9 w-full items-center gap-2 rounded-md py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"grid size-4 shrink-0 place-items-center text-muted-foreground\"\n        >\n          {icon ?? <KindIcon kind={kind} />}\n        </span>\n        <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n          <span className=\"min-w-0 truncate font-medium text-foreground/90\">\n            <ActionSwapRollText value={titleKey}>\n              {title}\n            </ActionSwapRollText>\n          </span>\n          {meta ? (\n            <span className=\"shrink-0 text-xs text-muted-foreground/60\">\n              <ActionSwapRollText value={metaKey}>\n                {meta}\n              </ActionSwapRollText>\n            </span>\n          ) : null}\n          <span className=\"min-w-0 truncate font-mono text-[11px] text-muted-foreground/55\">\n            <ActionSwapRollText value={toolKey}>\n              {tool}\n            </ActionSwapRollText>\n          </span>\n        </span>\n        <span\n          className={cn(\n            \"inline-flex shrink-0 items-center gap-1 text-[11px] font-medium\",\n            getStatusClass(status),\n          )}\n        >\n          <StatusIcon status={status} reduce={reduce} />\n          <ActionSwapRollText value={status}>{statusLabel}</ActionSwapRollText>\n        </span>\n        <motion.span\n          aria-hidden=\"true\"\n          animate={{ rotate: currentOpen ? 180 : 0 }}\n          transition={reduce ? { duration: 0 } : SPRING_SWAP}\n          className=\"shrink-0 text-muted-foreground/50 transition-colors group-hover:text-muted-foreground\"\n        >\n          <ChevronDown className=\"size-3.5\" />\n        </motion.span>\n      </button>\n\n      <AgentDisclosure\n        id={contentId}\n        role=\"region\"\n        aria-labelledby={triggerId}\n        open={currentOpen}\n      >\n        <div className=\"pl-6 pt-1.5\">\n          <div className=\"overflow-hidden rounded-xl bg-muted/80\">\n          <div\n            ref={viewportRef}\n            role=\"log\"\n            aria-live=\"polite\"\n            className=\"scrollbar-hide overflow-y-auto\"\n            style={{ maxHeight }}\n          >\n            <div className={cn(\"p-3\", contentClassName)}>{children}</div>\n          </div>\n\n            {canCopy || onRetry ? (\n              <div className=\"flex items-center gap-0.5 px-2 pb-1.5\">\n              {canCopy ? (\n                <ToolResultAction\n                  label={copied ? \"Copied\" : \"Copy result\"}\n                  onClick={handleCopy}\n                >\n                  {copied ? (\n                    <Check className=\"size-3.5\" />\n                  ) : (\n                    <Copy className=\"size-3.5\" />\n                  )}\n                </ToolResultAction>\n              ) : null}\n              {onRetry ? (\n                <ToolResultAction label=\"Run again\" onClick={onRetry}>\n                  <RotateCcw className=\"size-3.5\" />\n                </ToolResultAction>\n              ) : null}\n              <span className=\"ml-auto text-[11px] text-muted-foreground/55\">\n                <ActionSwapRollText value={status}>\n                  {statusLabel}\n                </ActionSwapRollText>\n              </span>\n              </div>\n            ) : null}\n          </div>\n        </div>\n      </AgentDisclosure>\n    </div>\n  );\n}\n"},{"path":"components/agents/agent-code.tsx","type":"registry:component","target":"@components/agents/agent-code.tsx","content":"\"use client\";\n\nimport {\n  type CSSProperties,\n  Fragment,\n  useEffect,\n  useState,\n} from \"react\";\nimport { createHighlighter, type Highlighter } from \"shiki\";\nimport { cn } from \"@/lib/utils\";\n\nexport type AgentCodeLanguage =\n  | \"bash\"\n  | \"diff\"\n  | \"json\"\n  | \"text\"\n  | \"tsx\"\n  | \"typescript\";\n\nexport interface AgentCodeToken {\n  content: string;\n  offset: number;\n  light?: string;\n  dark?: string;\n}\n\nexport type AgentCodeTokenLines = AgentCodeToken[][];\n\nexport interface AgentCodeProps {\n  code: string;\n  language?: AgentCodeLanguage;\n  className?: string;\n}\n\nexport interface AgentCodeLineProps {\n  code: string;\n  tokens?: AgentCodeToken[];\n  className?: string;\n}\n\nconst LIGHT_THEME = \"github-light-high-contrast\";\nconst DARK_THEME = \"github-dark-high-contrast\";\nlet agentCodeHighlighter: Promise<Highlighter> | null = null;\nconst tokenCache = new Map<string, AgentCodeTokenLines>();\n\nfunction getAgentCodeHighlighter() {\n  if (!agentCodeHighlighter) {\n    agentCodeHighlighter = createHighlighter({\n      themes: [LIGHT_THEME, DARK_THEME],\n      langs: [\"bash\", \"diff\", \"json\", \"tsx\", \"typescript\"],\n    });\n  }\n  return agentCodeHighlighter;\n}\n\nfunction tokenCacheKey(code: string, language: AgentCodeLanguage) {\n  return `${language}\\u0000${code}`;\n}\n\nexport function useAgentCodeTokens(\n  code: string,\n  language: AgentCodeLanguage,\n) {\n  const key = tokenCacheKey(code, language);\n  const cached = tokenCache.get(key);\n  const [result, setResult] = useState<{\n    key: string;\n    code: string;\n    language: AgentCodeLanguage;\n    lines: AgentCodeTokenLines;\n  } | null>(cached ? { key, code, language, lines: cached } : null);\n\n  useEffect(() => {\n    const current = tokenCache.get(key);\n    if (current) {\n      setResult({ key, code, language, lines: current });\n      return;\n    }\n\n    let cancelled = false;\n    getAgentCodeHighlighter().then((highlighter) => {\n      if (cancelled) return;\n      const lines = highlighter\n        .codeToTokensWithThemes(code, {\n          lang: language,\n          themes: {\n            light: LIGHT_THEME,\n            dark: DARK_THEME,\n          },\n        })\n        .map((line) =>\n          line.map((token) => ({\n            content: token.content,\n            offset: token.offset,\n            light: token.variants.light?.color,\n            dark: token.variants.dark?.color,\n          })),\n      );\n      tokenCache.set(key, lines);\n      setResult({ key, code, language, lines });\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, [code, key, language]);\n\n  if (result?.key === key) return result.lines;\n  if (result?.language === language && code.startsWith(result.code)) {\n    return result.lines;\n  }\n  return null;\n}\n\nexport function AgentCodeLine({\n  code,\n  tokens,\n  className,\n}: AgentCodeLineProps) {\n  return (\n    <span className={className}>\n      {tokens\n        ? tokens.map((token) => (\n            <span\n              key={`${token.offset}-${token.content}`}\n              style={\n                {\n                  \"--agent-code-light\": token.light ?? \"currentColor\",\n                  \"--agent-code-dark\": token.dark ?? token.light ?? \"currentColor\",\n                } as CSSProperties\n              }\n              className=\"text-[var(--agent-code-light)] dark:text-[var(--agent-code-dark)]\"\n            >\n              {token.content}\n            </span>\n          ))\n        : code}\n    </span>\n  );\n}\n\nexport function AgentCode({\n  code,\n  language = \"bash\",\n  className,\n}: AgentCodeProps) {\n  const tokens = useAgentCodeTokens(code, language);\n  let offset = 0;\n  const lines = code.split(\"\\n\").map((content) => {\n    const line = { content, offset };\n    offset += content.length + 1;\n    return line;\n  });\n\n  return (\n    <pre\n      className={cn(\n        \"m-0 overflow-x-auto whitespace-pre font-mono text-xs leading-5 text-foreground/85\",\n        className,\n      )}\n    >\n      <code>\n        {lines.map((line, index) => (\n          <Fragment key={line.offset}>\n            <AgentCodeLine code={line.content} tokens={tokens?.[index]} />\n            {index < lines.length - 1 ? \"\\n\" : null}\n          </Fragment>\n        ))}\n      </code>\n    </pre>\n  );\n}\n"},{"path":"components/agents/agent-disclosure.tsx","type":"registry:component","target":"@components/agents/agent-disclosure.tsx","content":"\"use client\";\n\nimport { motion, type HTMLMotionProps, useReducedMotion } from \"motion/react\";\nimport type { CSSProperties } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AgentDisclosureProps\n  extends Omit<HTMLMotionProps<\"div\">, \"animate\" | \"initial\"> {\n  open: boolean;\n  openHeight?: CSSProperties[\"height\"];\n}\n\n/** Shared transform-only reveal for collapsible agent content. */\nexport function AgentDisclosure({\n  open,\n  openHeight = \"auto\",\n  className,\n  style,\n  transition,\n  ...props\n}: AgentDisclosureProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <motion.div\n      {...props}\n      aria-hidden={!open}\n      inert={!open}\n      initial={false}\n      animate={\n        reduce\n          ? { opacity: open ? 1 : 0 }\n          : {\n              opacity: open ? 1 : 0,\n              clipPath: open ? \"inset(0 0 0% 0)\" : \"inset(0 0 100% 0)\",\n              y: open ? 0 : -4,\n            }\n      }\n      transition={\n        transition ?? {\n          duration: reduce ? 0 : open ? 0.22 : 0.14,\n          ease: EASE_OUT,\n        }\n      }\n      className={cn(\"overflow-hidden\", className)}\n      style={{\n        ...style,\n        height: open ? openHeight : 0,\n        pointerEvents: open ? undefined : \"none\",\n        transformOrigin: \"top\",\n      }}\n    />\n  );\n}\n"},{"path":"components/motion/action-swap-roll.tsx","type":"registry:component","target":"@components/motion/action-swap-roll.tsx","content":"\"use client\";\n\nimport {\n  ActionSwapButton,\n  ActionSwapIcon,\n  ActionSwapText,\n  type ActionSwapButtonProps,\n  type ActionSwapIconProps,\n  type ActionSwapTextProps,\n} from \"./action-swap\";\n\nexport type {\n  ActionSwapButtonSize,\n  ActionSwapButtonVariant,\n  ActionSwapItem,\n} from \"./action-swap\";\n\nexport type ActionSwapRollButtonProps = Omit<ActionSwapButtonProps, \"animation\">;\nexport type ActionSwapRollTextProps = Omit<ActionSwapTextProps, \"animation\">;\nexport type ActionSwapRollIconProps = Omit<ActionSwapIconProps, \"animation\">;\n\nexport function ActionSwapRollButton(props: ActionSwapRollButtonProps) {\n  return <ActionSwapButton {...props} animation=\"roll\" />;\n}\n\nexport function ActionSwapRollText(props: ActionSwapRollTextProps) {\n  return <ActionSwapText {...props} animation=\"roll\" />;\n}\n\nexport function ActionSwapRollIcon(props: ActionSwapRollIconProps) {\n  return <ActionSwapIcon {...props} animation=\"roll\" />;\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/motion/action-swap.tsx","type":"registry:component","target":"@components/motion/action-swap.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from \"motion/react\";\nimport { useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ActionSwapItem = {\n  id: string;\n  label: ReactNode;\n  icon?: ReactNode;\n  ariaLabel?: string;\n};\n\nexport type ActionSwapButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\";\nexport type ActionSwapButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon\";\nexport type ActionSwapAnimation = \"blur\" | \"roll\" | \"cascade\";\n\n/** Animations with a single-element variant set (cascade animates per letter). */\ntype CoreAnimation = \"blur\" | \"roll\";\n\nexport interface ActionSwapButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\" | \"onChange\"\n> {\n  items: ActionSwapItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string, item: ActionSwapItem) => void;\n  variant?: ActionSwapButtonVariant;\n  size?: ActionSwapButtonSize;\n  animation?: ActionSwapAnimation;\n  iconOnly?: boolean;\n  cycle?: boolean;\n}\n\nexport interface ActionSwapTextProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nexport interface ActionSwapIconProps {\n  value: string;\n  children: ReactNode;\n  animation?: ActionSwapAnimation;\n  className?: string;\n}\n\nconst BLUR_TRANSITION = { duration: 0.2, ease: \"easeInOut\" } as const;\nconst ROLL_TRANSITION = SPRING_SWAP;\nconst ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;\nconst SWAP_BLUR = \"blur(8px)\";\nconst ROLL_BLUR = \"blur(3px)\";\n\n// Cascade rolls the label one letter at a time, left to right. The leaving\n// and landing strings overlap as independent layers (no shared cells), so\n// proportional glyph widths never jitter. Exits cascade at half the enter\n// stagger so the tail of the old label lingers briefly.\nconst CASCADE_STAGGER = 0.025;\n\nconst CASCADE_LETTER_VARIANTS: Variants = {\n  initial: { opacity: 0, y: \"105%\", filter: ROLL_BLUR },\n  animate: (delay: number = 0) => ({\n    opacity: 1,\n    y: \"0%\",\n    filter: \"blur(0px)\",\n    transition: { ...SPRING_SWAP, delay },\n  }),\n  exit: (delay: number = 0) => ({\n    opacity: 0,\n    y: \"-105%\",\n    filter: ROLL_BLUR,\n    transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },\n  }),\n};\n\nconst TEXT_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.94,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: \"90%\", filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: \"0%\",\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: \"-90%\",\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst ICON_VARIANTS: Record<CoreAnimation, Variants> = {\n  blur: {\n    initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      transition: BLUR_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.25,\n      filter: SWAP_BLUR,\n      transition: BLUR_TRANSITION,\n    },\n  },\n  roll: {\n    initial: { opacity: 0, y: 12, filter: ROLL_BLUR },\n    animate: {\n      opacity: 1,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: ROLL_TRANSITION,\n    },\n    exit: {\n      opacity: 0,\n      y: -12,\n      filter: ROLL_BLUR,\n      transition: ROLL_EXIT_TRANSITION,\n    },\n  },\n};\n\nconst VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {\n  primary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  secondary: \"border border-border bg-card text-foreground hover:border-border\",\n  outline: \"border border-border bg-transparent text-foreground hover:bg-primary/5\",\n  ghost: \"text-muted-foreground hover:bg-primary/5 hover:text-foreground\",\n};\n\nconst SIZE_CLASS: Record<ActionSwapButtonSize, string> = {\n  sm: \"h-8 gap-1.5 rounded-full px-3 text-xs\",\n  md: \"h-10 gap-2 rounded-full px-4 text-sm\",\n  lg: \"h-12 gap-2.5 rounded-full px-5 text-base\",\n  icon: \"h-10 w-10 rounded-full\",\n};\n\nexport function ActionSwapText({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapTextProps) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number>();\n\n  useLayoutEffect(() => {\n    const nextWidth = measureRef.current?.offsetWidth;\n    if (!nextWidth) return;\n    setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));\n  });\n\n  // Cascade needs a plain string to split into letters; non-string content\n  // and reduced motion fall back to the closest single-element animation.\n  const label = typeof children === \"string\" ? children : null;\n  const cascade = animation === \"cascade\" && label !== null && !reduce;\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden whitespace-nowrap align-bottom\", className)}\n      style={{\n        width,\n        transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,\n      }}\n    >\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"invisible inline-block whitespace-nowrap\"\n      >\n        {children}\n      </span>\n      {cascade ? (\n        <>\n          {/* Letters are decorative fragments; readers get the whole label. */}\n          <span className=\"sr-only\">{label}</span>\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={`cascade-${value}`}\n              aria-hidden\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              className=\"absolute left-0 top-0 inline-block whitespace-pre\"\n            >\n              {label.split(\"\").map((char, i) => (\n                <motion.span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.\n                  key={i}\n                  custom={i * CASCADE_STAGGER}\n                  variants={CASCADE_LETTER_VARIANTS}\n                  className=\"inline-block whitespace-pre will-change-[opacity,filter,transform]\"\n                >\n                  {char}\n                </motion.span>\n              ))}\n            </motion.span>\n          </AnimatePresence>\n        </>\n      ) : (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={`${animation}-${value}`}\n            variants={TEXT_VARIANTS[coreAnimation]}\n            initial={reduce ? false : \"initial\"}\n            animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n            exit={reduce ? undefined : \"exit\"}\n            className=\"absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]\"\n          >\n            {children}\n          </motion.span>\n        </AnimatePresence>\n      )}\n    </span>\n  );\n}\n\nexport function ActionSwapIcon({\n  value,\n  children,\n  animation = \"blur\",\n  className,\n}: ActionSwapIconProps) {\n  const reduce = useReducedMotion();\n  // Icons are single elements — cascade maps to its closest motion, roll.\n  const coreAnimation: CoreAnimation =\n    animation === \"cascade\" ? \"roll\" : animation;\n\n  return (\n    <span className={cn(\"relative inline-grid shrink-0 place-items-center overflow-hidden\", className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={`${animation}-${value}`}\n          aria-hidden\n          variants={ICON_VARIANTS[coreAnimation]}\n          initial={reduce ? false : \"initial\"}\n          animate={reduce ? { opacity: 1, filter: \"blur(0px)\", scale: 1, y: 0 } : \"animate\"}\n          exit={reduce ? undefined : \"exit\"}\n          className=\"col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]\"\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ActionSwapButton({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  variant = \"secondary\",\n  size = \"md\",\n  animation = \"blur\",\n  iconOnly = size === \"icon\",\n  cycle = true,\n  className,\n  disabled,\n  onClick,\n  ...rest\n}: ActionSwapButtonProps) {\n  const reduce = useReducedMotion();\n  const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);\n  const currentValue = value ?? internalValue;\n  const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));\n  const activeItem = items[activeIndex] ?? items[0];\n  const hasIcon = items.some((item) => item.icon);\n  const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;\n\n  if (!activeItem) return null;\n\n  const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === \"string\" ? activeItem.label : undefined);\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"inline-flex items-center justify-center overflow-hidden font-medium transition-colors\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        VARIANT_CLASS[variant],\n        SIZE_CLASS[size],\n        className,\n      )}\n      aria-label={accessibleLabel}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented || disabled || !cycle || !nextItem) return;\n        if (value === undefined) setInternalValue(nextItem.id);\n        onValueChange?.(nextItem.id, nextItem);\n      }}\n      {...rest}\n    >\n      {hasIcon ? (\n        <ActionSwapIcon value={activeItem.id} animation={animation} className=\"h-4 w-4\">\n          {activeItem.icon ?? null}\n        </ActionSwapIcon>\n      ) : null}\n      {!iconOnly ? (\n        <ActionSwapText value={activeItem.id} animation={animation}>\n          {activeItem.label}\n        </ActionSwapText>\n      ) : null}\n    </motion.button>\n  );\n}\n"}]}