{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "selection-actions",
  "type": "registry:component",
  "title": "Selection actions",
  "description": "Highlight a passage and hand it to the agent to rewrite.",
  "categories": [
    "ai-interface"
  ],
  "dependencies": [
    "iconoir-react"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/button.json",
    "https://irisui.dev/r/shimmer.json",
    "https://irisui.dev/r/stream-text.json"
  ],
  "files": [
    {
      "path": "src/registry/components/selection-actions/selection-actions.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n  ArrowUp,\n  ChatBubbleQuestion,\n  Check,\n  EmojiSatisfied,\n  NavArrowRight,\n  Refresh,\n  Scissor,\n  Spark,\n  TextBox,\n  Xmark,\n} from \"iconoir-react\";\nimport { cn } from \"../lib/utils\";\nimport { Button } from \"../button/button\";\nimport { Shimmer } from \"../shimmer/shimmer\";\nimport { StreamText } from \"../stream-text/stream-text\";\n\n/* ─────────────────────────────────────────────────────────\n * SELECTION ACTIONS\n * A contextual AI bar attached beneath selected text.\n * The global theme owns its surface; this component only\n * composes existing surface, ink, accent, radius and motion\n * tokens.\n * ───────────────────────────────────────────────────────── */\n\n/* an Anasa daily note */\nconst LEAD = \"Morning pages go in the daily note. \";\nconst PICKED = \"Write three pages before checking anything else, so that the day starts with your own thoughts first.\";\nconst REWRITE = \"Write three pages before checking anything, so the day starts with your own thoughts.\";\n\n/* The passage: lead-in text, the selected `original`, and the streamed `rewrite`. */\nexport type SelectionText = {\n  lead: string;\n  original: string;\n  rewrite: string;\n};\n\n/* A single AI action offered in the bar. Omit `action` for a no-op button\n * (e.g. Explain); `busyLabel` is the gerund shown while it runs. */\nexport type SelectionAction = {\n  id: string;\n  icon: ReactNode;\n  action?: string;\n  busyLabel?: string;\n};\n\n/* The action set: `primary` are always visible; `more` reveal on expand. */\nexport type SelectionActionSet = {\n  primary: SelectionAction[];\n  more: SelectionAction[];\n};\n\n/* Prominent copy strings. */\nexport type SelectionActionsLabels = {\n  keep: string;\n  discard: string;\n  placeholder: string;\n};\n\nconst DEFAULT_TEXT: SelectionText = {\n  lead: LEAD,\n  original: PICKED,\n  rewrite: REWRITE,\n};\n\nconst DEFAULT_LABELS: SelectionActionsLabels = {\n  keep: \"Keep\",\n  discard: \"Discard\",\n  placeholder: \"Describe edits\",\n};\n\ntype Mode = \"idle\" | \"thinking\" | \"streaming\" | \"result\";\n\nconst iconProps = {\n  width: 14,\n  height: 14,\n  strokeWidth: 1.8,\n  \"aria-hidden\": true,\n} as const;\n\nconst icons = {\n  explain: <ChatBubbleQuestion {...iconProps} />,\n  improve: <Spark {...iconProps} />,\n  shorten: <Scissor {...iconProps} />,\n  tone: <EmojiSatisfied {...iconProps} />,\n  grammar: <TextBox {...iconProps} />,\n  send: <ArrowUp width=\"16\" height=\"16\" strokeWidth=\"2.4\" aria-hidden=\"true\" />,\n  chevron: <NavArrowRight {...iconProps} />,\n  check: <Check {...iconProps} />,\n  close: <Xmark {...iconProps} />,\n  retry: <Refresh {...iconProps} />,\n};\n\n/* the single \"keep\" affirm — solid ink with a hairline (not the atom's filled\n * highlight) shadow, so it stays a local one-off rather than a Button variant */\nconst primary =\n  \"inline-flex h-7 shrink-0 items-center gap-1 rounded-button bg-ink px-2.5 text-[12.5px] font-normal text-canvas shadow-hairline transition-[opacity,transform] duration-150 hover:opacity-90 active:scale-[0.96]\";\n\nconst DEFAULT_ACTIONS: SelectionActionSet = {\n  primary: [\n    { id: \"Explain\", icon: icons.explain },\n    { id: \"Improve\", icon: icons.improve, action: \"Improve\", busyLabel: \"Improving\" },\n  ],\n  more: [\n    { id: \"Shorten\", icon: icons.shorten, action: \"Shorten\", busyLabel: \"Shortening\" },\n    { id: \"Tone\", icon: icons.tone, action: \"Change tone\", busyLabel: \"Changing tone\" },\n    { id: \"Grammar\", icon: icons.grammar, action: \"Fix grammar\" },\n  ],\n};\n\nexport type SelectionActionsProps = {\n  /** Accepted for gallery/registry parity; not used by this bar. */\n  variant?: string;\n  /** The passage shown above the bar. */\n  text?: Partial<SelectionText>;\n  /** The AI actions offered in the bar. */\n  actions?: SelectionActionSet;\n  /** Prominent copy strings. */\n  labels?: Partial<SelectionActionsLabels>;\n  /** Called with the action name whenever an edit is run. */\n  onAction?: (action: string) => void;\n};\n\nexport default function SelectionActions({\n  text: textProp,\n  actions = DEFAULT_ACTIONS,\n  labels,\n  onAction,\n}: SelectionActionsProps = {}) {\n  const passage = { ...DEFAULT_TEXT, ...textProp };\n  const copy = { ...DEFAULT_LABELS, ...labels };\n  const [shown, setShown] = useState(false);\n  const [mode, setMode] = useState<Mode>(\"idle\");\n  const [action, setAction] = useState(\"Improve\");\n  const [prompt, setPrompt] = useState(\"\");\n  const [typingWidth, setTypingWidth] = useState<number | null>(null);\n  const [expanded, setExpanded] = useState(false);\n  const [anchor, setAnchor] = useState({ x: 0, y: 0 });\n  const [positioned, setPositioned] = useState(false);\n\n  const hostRef = useRef<HTMLDivElement>(null);\n  const selectionRef = useRef<HTMLSpanElement>(null);\n  const barRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const frameRef = useRef<number | null>(null);\n  const previousModeRef = useRef<Mode>(\"idle\");\n  const lastWidthRef = useRef(0);\n  const widthAnimationRef = useRef<Animation | null>(null);\n\n  useEffect(() => {\n    const timer = window.setTimeout(() => setShown(true), 280);\n    return () => window.clearTimeout(timer);\n  }, []);\n\n  useEffect(() => {\n    if (mode !== \"thinking\") return;\n    const timer = window.setTimeout(() => setMode(\"streaming\"), 700);\n    return () => window.clearTimeout(timer);\n  }, [mode]);\n\n  /* Attach beneath the final selected line, while centering the bar\n   * against the complete selection bounds. requestAnimationFrame batches\n   * streaming reflow measurements and avoids visible intermediate positions. */\n  const place = useCallback(() => {\n    if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n    frameRef.current = requestAnimationFrame(() => {\n      const host = hostRef.current;\n      const selection = selectionRef.current;\n      if (!host || !selection) return;\n\n      const bounds = selection.getBoundingClientRect();\n      const lines = Array.from(selection.getClientRects());\n      const lastLine = lines.at(-1);\n      if (!lastLine) return;\n\n      const hostBounds = host.getBoundingClientRect();\n      const next = {\n        x: Math.round(bounds.left - hostBounds.left + bounds.width / 2),\n        y: Math.round(lastLine.bottom - hostBounds.top + 8),\n      };\n\n      setAnchor((current) => (current.x === next.x && current.y === next.y ? current : next));\n      setPositioned(true);\n    });\n  }, []);\n\n  useLayoutEffect(() => {\n    place();\n  }, [mode, place]);\n\n  useEffect(() => {\n    const host = hostRef.current;\n    if (!host) return;\n    const observer = new ResizeObserver(place);\n    observer.observe(host);\n    window.addEventListener(\"resize\", place);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"resize\", place);\n      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n    };\n  }, [place]);\n\n  /* Intrinsic width handles the preset expansion. When the entire content\n   * changes between idle, loading and confirmation, animate from the last\n   * rendered width to the new intrinsic width before the browser paints. */\n  useLayoutEffect(() => {\n    const bar = barRef.current;\n    const content = contentRef.current;\n    if (!bar || !content) return;\n\n    const nextWidth = Math.ceil(content.getBoundingClientRect().width) + 8;\n    const previousWidth = lastWidthRef.current || Math.ceil(bar.getBoundingClientRect().width);\n\n    if (previousModeRef.current !== mode && Math.abs(nextWidth - previousWidth) > 1) {\n      widthAnimationRef.current?.cancel();\n      const animation = bar.animate([{ width: `${previousWidth}px` }, { width: `${nextWidth}px` }], {\n        duration: 320,\n        easing: \"cubic-bezier(0.23,1,0.32,1)\",\n      });\n      widthAnimationRef.current = animation;\n      animation.onfinish = () => {\n        lastWidthRef.current = nextWidth;\n        widthAnimationRef.current = null;\n      };\n    } else {\n      lastWidthRef.current = nextWidth;\n    }\n\n    previousModeRef.current = mode;\n  }, [mode]);\n\n  useEffect(() => {\n    const content = contentRef.current;\n    if (!content) return;\n\n    const observer = new ResizeObserver(() => {\n      if (widthAnimationRef.current?.playState === \"running\") return;\n      lastWidthRef.current = Math.ceil(content.getBoundingClientRect().width) + 8;\n    });\n    observer.observe(content);\n    return () => {\n      observer.disconnect();\n      widthAnimationRef.current?.cancel();\n    };\n  }, []);\n\n  const run = (nextAction: string) => {\n    setAction(nextAction);\n    setExpanded(false);\n    setMode(\"thinking\");\n    onAction?.(nextAction);\n  };\n\n  const reset = () => {\n    setExpanded(false);\n    setPrompt(\"\");\n    setTypingWidth(null);\n    setAction(\"Improve\");\n    setMode(\"idle\");\n  };\n\n  const busy = mode === \"thinking\" || mode === \"streaming\";\n  const visible = shown && positioned;\n  const hasPrompt = prompt.trim().length > 0;\n  const busyLabelMap: Record<string, string> = {};\n  for (const item of [...actions.primary, ...actions.more]) {\n    if (item.action && item.busyLabel) busyLabelMap[item.action] = item.busyLabel;\n  }\n  const busyLabel = busyLabelMap[action] ?? \"Editing\";\n\n  return (\n    <div className=\"w-full max-w-[460px]\">\n      <div ref={hostRef} className=\"relative select-none pb-12\">\n        <p className=\"text-[13px] leading-relaxed text-ink\">\n          {passage.lead}\n          <span\n            ref={selectionRef}\n            className=\"box-decoration-clone rounded-[3px] bg-[color-mix(in_srgb,var(--accent)_14%,var(--surface))] text-ink dark:bg-accent-tint\"\n          >\n            {mode === \"idle\" || mode === \"thinking\" ? (\n              passage.original\n            ) : mode === \"streaming\" ? (\n              <StreamText text={passage.rewrite} onProgress={place} onDone={() => setMode(\"result\")} />\n            ) : (\n              passage.rewrite\n            )}\n          </span>\n        </p>\n\n        <div\n          className=\"absolute top-0 left-0 z-10\"\n          style={{\n            transform: `translate3d(${anchor.x}px, ${anchor.y}px, 0) translateX(-50%)`,\n            transition: \"transform 320ms cubic-bezier(0.77,0,0.175,1), opacity 180ms ease-out\",\n            opacity: visible ? 1 : 0,\n            pointerEvents: visible ? \"auto\" : \"none\",\n            willChange: \"transform\",\n          }}\n        >\n          {/* A 36px pill wraps 28px controls at a 4px inset. The controls\n              resolve to a 14px radius, preserving the concentric curve. */}\n          <div\n            ref={barRef}\n            className={cn(\n              \"flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-button bg-surface p-1 font-sans font-normal text-ink shadow-overlay\",\n              visible && \"animate-selection-actions-bar-in\",\n            )}\n            style={{\n              width: mode === \"idle\" && hasPrompt && typingWidth ? typingWidth : undefined,\n            }}\n          >\n            <div\n              ref={contentRef}\n              className=\"flex w-fit shrink-0 items-center justify-center gap-0.5\"\n              style={{\n                width: mode === \"idle\" && hasPrompt && typingWidth ? typingWidth - 8 : undefined,\n              }}\n            >\n              {busy && (\n                <span className=\"inline-flex h-7 items-center gap-1.5 whitespace-nowrap px-2.5 text-[12.5px] font-normal text-ink-2\">\n                  <span className=\"size-3 shrink-0 animate-selection-actions-spin rounded-full border-[1.5px] border-line-strong border-t-ink-2\" />\n                  {mode === \"thinking\" ? (\n                    <Shimmer className=\"text-[12.5px] font-normal\">{busyLabel}…</Shimmer>\n                  ) : (\n                    <span>{busyLabel}…</span>\n                  )}\n                </span>\n              )}\n\n              {mode === \"result\" && (\n                <>\n                  <button type=\"button\" onClick={reset} className={primary}>\n                    {icons.check}\n                    {copy.keep}\n                  </button>\n                  <Button type=\"button\" variant=\"quiet\" size=\"xs\" className=\"shrink-0\" onClick={reset}>\n                    {icons.close}\n                    {copy.discard}\n                  </Button>\n                  <span className=\"mx-0.5 h-4 w-px shrink-0 bg-line\" />\n                  <button\n                    type=\"button\"\n                    aria-label=\"Try again\"\n                    onClick={() => run(action)}\n                    className=\"flex size-7 shrink-0 items-center justify-center rounded-button text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink-2 active:scale-[0.96]\"\n                  >\n                    {icons.retry}\n                  </button>\n                </>\n              )}\n\n              {mode === \"idle\" && (\n                <>\n                  <div\n                    className=\"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-400\"\n                    style={{\n                      maxWidth: expanded ? 0 : hasPrompt && typingWidth ? typingWidth - 40 : 145,\n                      opacity: expanded ? 0 : 1,\n                      transform: expanded ? \"translateX(-8px)\" : \"translateX(0)\",\n                      transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                    }}\n                  >\n                    <form\n                      className=\"flex h-7 shrink-0 items-center transition-[width] duration-400\"\n                      style={{\n                        width: hasPrompt && typingWidth ? typingWidth - 40 : 145,\n                        transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                      }}\n                      onSubmit={(event) => {\n                        event.preventDefault();\n                        run(prompt.trim() || \"Improve\");\n                      }}\n                    >\n                      <input\n                        value={prompt}\n                        onChange={(event) => {\n                          const next = event.target.value;\n                          if (!prompt.trim() && next.trim()) {\n                            setTypingWidth(Math.ceil(barRef.current?.getBoundingClientRect().width ?? 0));\n                          } else if (!next.trim()) {\n                            setTypingWidth(null);\n                          }\n                          setPrompt(next);\n                        }}\n                        aria-label={copy.placeholder}\n                        placeholder={copy.placeholder}\n                        className=\"h-7 w-full bg-transparent pr-2.5 pl-3 text-[12.5px] text-ink placeholder:text-ink-3\"\n                      />\n                    </form>\n                  </div>\n\n                  <div\n                    className=\"flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity,transform] duration-400\"\n                    style={{\n                      maxWidth: hasPrompt ? 0 : expanded ? 462 : 224,\n                      opacity: hasPrompt ? 0 : 1,\n                      transform: hasPrompt ? \"translateX(-8px)\" : \"translateX(0)\",\n                      transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                    }}\n                  >\n                    {!expanded && <span className=\"mx-1 h-4 w-px shrink-0 bg-line-strong\" />}\n                    {actions.primary.map((item) => (\n                      <Button\n                        key={item.id}\n                        type=\"button\"\n                        variant=\"quiet\"\n                        size=\"xs\"\n                        className=\"shrink-0\"\n                        onClick={item.action ? () => run(item.action!) : undefined}\n                      >\n                        {item.icon}\n                        {item.id}\n                      </Button>\n                    ))}\n\n                    <div\n                      className=\"flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity,margin] duration-400\"\n                      style={{\n                        maxWidth: expanded ? 262 : 0,\n                        opacity: expanded ? 1 : 0,\n                        marginLeft: expanded ? 2 : 0,\n                        transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                      }}\n                    >\n                      {actions.more.map((item) => (\n                        <Button\n                          key={item.id}\n                          type=\"button\"\n                          variant=\"quiet\"\n                          size=\"xs\"\n                          className=\"shrink-0\"\n                          onClick={item.action ? () => run(item.action!) : undefined}\n                        >\n                          {item.icon}\n                          {item.id}\n                        </Button>\n                      ))}\n                    </div>\n\n                    <span className=\"mx-0.5 h-4 w-px shrink-0 bg-line\" />\n                    <button\n                      type=\"button\"\n                      aria-label={expanded ? \"Show fewer actions\" : \"Show more actions\"}\n                      aria-expanded={expanded}\n                      onClick={() => setExpanded((value) => !value)}\n                      className=\"flex size-7 shrink-0 items-center justify-center rounded-button text-ink transition-[background-color,transform] duration-200 hover:bg-hover active:scale-[0.96]\"\n                    >\n                      <span\n                        className=\"flex transition-transform duration-400\"\n                        style={{\n                          transform: expanded ? \"rotate(180deg)\" : \"rotate(0deg)\",\n                          transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                        }}\n                      >\n                        {icons.chevron}\n                      </span>\n                    </button>\n                  </div>\n\n                  <div\n                    className=\"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-400\"\n                    style={{\n                      maxWidth: hasPrompt ? 30 : 0,\n                      opacity: hasPrompt ? 1 : 0,\n                      transform: hasPrompt ? \"scale(1)\" : \"scale(0.88)\",\n                      transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n                    }}\n                  >\n                    <button\n                      type=\"button\"\n                      aria-label=\"Send edit instruction\"\n                      onClick={() => run(prompt.trim())}\n                      className=\"flex size-7 shrink-0 items-center justify-center rounded-button bg-ink text-surface transition-[opacity,transform] duration-200 active:scale-[0.94]\"\n                    >\n                      {icons.send}\n                    </button>\n                  </div>\n                </>\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/selection-actions/selection-actions.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/selection-actions",
  "meta": {
    "kind": "component",
    "group": "AI interface",
    "export": "SelectionActions",
    "docs": "https://irisui.dev/components/selection-actions"
  }
}
