{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-selection-menu",
  "title": "AI Selection Menu",
  "description": "An Ask-AI row that floats over a text selection — action shortcuts, submenus, a free-form question field, one captured excerpt and a keyboard path in.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/ai-selection-menu.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ChevronDown,\n  CornerDownLeft,\n  Languages,\n  Lightbulb,\n  ListMinus,\n  Loader2,\n  MessageCircleQuestion,\n  PenLine,\n  Sparkles,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/* -------------------------------------------------------------------------- *\n * Model\n *\n * The selected text is frozen into the offer the moment the menu appears and is\n * never read back off the DOM afterwards. Every way out of this menu moves\n * focus — a button press, a submenu, and above all the question field, which is\n * a real <input> and therefore collapses the page selection the instant it is\n * focused. A payload derived at click time would be empty exactly when the\n * reader asked for the most work.\n * -------------------------------------------------------------------------- */\n\nexport interface AiSelectionOption {\n  /** Handed back as `option` on the request. */\n  value: string\n  label: string\n  /** Second line of the submenu row — the difference between two nearby options. */\n  description?: string\n}\n\nexport interface AiSelectionAction {\n  /** Handed back as `type` — the discriminant your handler switches on. */\n  type: string\n  label: string\n  /** Rendered in a fixed 14px slot; pass a bare lucide icon. */\n  icon?: React.ReactNode\n  /** Non-empty: the item opens a submenu and only fires once an option is picked. */\n  options?: AiSelectionOption[]\n  /** Reveals a one-line question field instead of firing immediately. */\n  ask?: boolean\n  /** Placeholder of that field. */\n  askPlaceholder?: string\n  /** Rendered, greyed out and skipped by keyboard navigation. */\n  disabled?: boolean\n}\n\nexport interface AiSelectionRequest {\n  /** The `type` of the action that fired. */\n  type: string\n  /** Normalized excerpt, capped at `maxLength`. Line breaks survive — code needs them. */\n  selectionText: string\n  /** Submenu choice, when the action declared `options`. */\n  option?: string\n  /** What the reader typed, when the action declared `ask`. */\n  query?: string\n  /** The excerpt hit `maxLength`, so `selectionText` is a prefix of the selection. */\n  truncated: boolean\n  /** Length of the whole selection before truncation. */\n  chars: number\n}\n\nexport interface AiSelectionControls {\n  /** Dismiss the menu. The escape hatch for `closeOnAction={false}`. */\n  close: () => void\n}\n\n/** Characters handed to the model. Past this the excerpt is cut, never dropped. */\nconst DEFAULT_MAX_LENGTH = 4000\n/** Below this a \"selection\" is a stray double-click, not a question. */\nconst DEFAULT_MIN_LENGTH = 3\n/** Distance between the selection box and the menu, in px. */\nconst GAP = 8\n/** Keep-out margin from the viewport edge when deciding above vs below. */\nconst VIEWPORT_MARGIN = 8\n/** Keep-out margin from the scope's own left/right edges. */\nconst EDGE_MARGIN = 8\n/**\n * Reserved heights for the above/below decision, in px. They describe the menu\n * at its tallest, not its current size — see the `placement` derivation.\n */\nconst ROW_HEIGHT = 38\nconst ASK_ROW_HEIGHT = 37\nconst NOTE_ROW_HEIGHT = 25\n\n/**\n * Trim the raw selection into something a model can read.\n *\n * Unlike a quote chip this does NOT collapse whitespace: indentation and line\n * breaks are meaning when the selection is code or a list, and the excerpt is\n * going into a prompt, not into a one-line label. Only carriage returns are\n * normalized and trailing spaces before a newline are dropped.\n *\n * The cut prefers the last word boundary in the tail — a prompt that ends\n * mid-identifier reads as corrupt input — unless that boundary throws away more\n * than a fifth of the budget, in which case the hard cut wins.\n */\nexport function normalizeSelectionText(\n  raw: string,\n  maxLength: number = DEFAULT_MAX_LENGTH,\n): { text: string; truncated: boolean; chars: number } {\n  const normalized = raw.replace(/\\r\\n?/g, \"\\n\").replace(/[ \\t]+\\n/g, \"\\n\").trim()\n  const chars = normalized.length\n  if (chars <= maxLength) return { chars, text: normalized, truncated: false }\n  const hard = normalized.slice(0, maxLength)\n  const boundary = Math.max(hard.lastIndexOf(\" \"), hard.lastIndexOf(\"\\n\"))\n  const cut = boundary > maxLength * 0.8 ? hard.slice(0, boundary) : hard\n  return { chars, text: cut.trimEnd(), truncated: true }\n}\n\nexport const DEFAULT_AI_SELECTION_ACTIONS: AiSelectionAction[] = [\n  { icon: <ListMinus />, label: \"Summarize\", type: \"summarize\" },\n  { icon: <Lightbulb />, label: \"Explain\", type: \"explain\" },\n  {\n    icon: <Languages />,\n    label: \"Translate\",\n    options: [\n      { label: \"Simplified Chinese\", value: \"zh-Hans\" },\n      { label: \"Japanese\", value: \"ja\" },\n      { label: \"Spanish\", value: \"es\" },\n      { label: \"French\", value: \"fr\" },\n      { label: \"German\", value: \"de\" },\n    ],\n    type: \"translate\",\n  },\n  {\n    icon: <PenLine />,\n    label: \"Rewrite\",\n    options: [\n      { description: \"Same meaning, fewer words\", label: \"Shorter\", value: \"shorter\" },\n      { description: \"Expand with detail and examples\", label: \"Longer\", value: \"longer\" },\n      { description: \"Neutral, professional register\", label: \"More formal\", value: \"formal\" },\n      { description: \"Plain language, short sentences\", label: \"Simpler\", value: \"simpler\" },\n    ],\n    type: \"rewrite\",\n  },\n  { ask: true, icon: <MessageCircleQuestion />, label: \"Ask\", type: \"ask\" },\n]\n\n/* -------------------------------------------------------------------------- *\n * Selection plumbing\n * -------------------------------------------------------------------------- */\n\ninterface Anchor {\n  /** Excerpt captured at offer time. */\n  text: string\n  truncated: boolean\n  chars: number\n  /** Scope-relative centre of the selection box. */\n  x: number\n  /** Scope-relative top / bottom of the selection box. */\n  top: number\n  bottom: number\n  scopeWidth: number\n  /** Viewport-space top at capture time; only feeds the above/below decision. */\n  viewportTop: number\n  /** Identity of this exact range — what the one-shot lock compares. */\n  signature: string\n}\n\n/** A selection inside a form field belongs to that field, never to this menu. */\nfunction inFormField(node: Node): boolean {\n  const element = node.nodeType === 1 ? (node as Element) : node.parentElement\n  return element?.closest(\"input, textarea, select\") != null\n}\n\n/** A selection inside a rich-text editor — opt in with `includeEditable`. */\nfunction inEditable(node: Node): boolean {\n  const element = node.nodeType === 1 ? (node as Element) : node.parentElement\n  return element?.closest(\"[contenteditable]:not([contenteditable='false'])\") != null\n}\n\n/**\n * Sub-pixel geometry churn must not re-render the scope. `viewportTop` is\n * deliberately excluded: it changes with every scroll and only ever fed the\n * one-time flip decision, so keeping the previous value stops the menu from\n * jumping from above the selection to below it while the page scrolls.\n */\nfunction sameAnchor(a: Anchor | null, b: Anchor): boolean {\n  return (\n    a !== null &&\n    a.signature === b.signature &&\n    Math.round(a.x) === Math.round(b.x) &&\n    Math.round(a.top) === Math.round(b.top) &&\n    Math.round(a.bottom) === Math.round(b.bottom) &&\n    Math.round(a.scopeWidth) === Math.round(b.scopeWidth)\n  )\n}\n\n/* -------------------------------------------------------------------------- *\n * Component\n * -------------------------------------------------------------------------- */\n\nexport interface AiSelectionMenuProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The text this menu watches. Anything selectable can live in here. */\n  children?: React.ReactNode\n  /** The action row. Defaults to summarize / explain / translate / rewrite / ask. */\n  actions?: AiSelectionAction[]\n  /**\n   * Fired once per activation. Nothing is executed here: run the model, open a\n   * panel, insert a diff — the menu only reports what was asked and about what.\n   */\n  onAction: (request: AiSelectionRequest, controls: AiSelectionControls) => void\n  /** Called whenever the menu appears or disappears. */\n  onOpenChange?: (open: boolean) => void\n  /** Brand label in front of the row. Default \"Ask AI\". */\n  label?: string\n  /** Excerpt budget in characters. Default 4000. */\n  maxLength?: number\n  /** Shorter selections are ignored. Default 3. */\n  minLength?: number\n  /** `\"auto\"` puts the row above the selection whenever the viewport allows. */\n  placement?: \"auto\" | \"top\" | \"bottom\"\n  /** `false` keeps the menu up after an action — pair it with `pending`. */\n  closeOnAction?: boolean\n  /** Work is in flight: the row goes busy and every item goes aria-disabled (still focusable). */\n  pending?: boolean\n  /** Also offer the menu for selections inside a contenteditable in this scope. */\n  includeEditable?: boolean\n  /** No menu at all — for a message that is still streaming, or redacted. */\n  disabled?: boolean\n}\n\n/**\n * Wraps a region of text and floats an \"Ask AI\" row over any selection made\n * inside it. It is a detection shell: the children keep their element identity,\n * so React skips the whole passage when the offer appears and disappears.\n */\nexport const AiSelectionMenu = React.forwardRef<HTMLDivElement, AiSelectionMenuProps>(function AiSelectionMenu(\n  {\n    actions = DEFAULT_AI_SELECTION_ACTIONS,\n    onAction,\n    onOpenChange,\n    label = \"Ask AI\",\n    maxLength = DEFAULT_MAX_LENGTH,\n    minLength = DEFAULT_MIN_LENGTH,\n    placement = \"auto\",\n    closeOnAction = true,\n    pending = false,\n    includeEditable = false,\n    disabled = false,\n    className,\n    children,\n    ...rest\n  },\n  forwardedRef,\n) {\n  const scopeId = React.useId()\n  const scopeRef = React.useRef<HTMLDivElement | null>(null)\n  const contentRef = React.useRef<HTMLDivElement | null>(null)\n  const [anchor, setAnchor] = React.useState<Anchor | null>(null)\n\n  /** A pointer is down: the selection is still being dragged, so hold the offer. */\n  const draggingRef = React.useRef(false)\n  const frameRef = React.useRef<number | null>(null)\n  /**\n   * The reader is working inside the menu. From here on the DOM selection is no\n   * longer the source of truth — focusing the question field collapses it — so\n   * detection is suspended until the menu closes.\n   */\n  const latchedRef = React.useRef(false)\n  /**\n   * Signature of the range already dealt with: asked about, or dismissed with\n   * Escape. Released when the selection collapses or a new gesture starts, so\n   * the same sentence can be asked about twice.\n   */\n  const settledRef = React.useRef<string | null>(null)\n  const anchorRef = React.useRef<Anchor | null>(null)\n\n  const latest = React.useRef({ closeOnAction, disabled, includeEditable, maxLength, minLength, onAction, onOpenChange })\n  React.useEffect(() => {\n    latest.current = { closeOnAction, disabled, includeEditable, maxLength, minLength, onAction, onOpenChange }\n  })\n  React.useEffect(() => {\n    anchorRef.current = anchor\n  }, [anchor])\n\n  const setScopeRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      scopeRef.current = node\n      if (typeof forwardedRef === \"function\") forwardedRef(node)\n      else if (forwardedRef) forwardedRef.current = node\n    },\n    [forwardedRef],\n  )\n\n  /** Close the menu. `lock` keeps this exact range quiet until a new gesture. */\n  const dismiss = React.useCallback((lock: boolean) => {\n    latchedRef.current = false\n    if (lock && anchorRef.current) settledRef.current = anchorRef.current.signature\n    setAnchor(null)\n  }, [])\n\n  const latch = React.useCallback(() => {\n    latchedRef.current = true\n  }, [])\n\n  const controls = React.useMemo<AiSelectionControls>(() => ({ close: () => dismiss(true) }), [dismiss])\n\n  const handleRequest = React.useCallback(\n    (request: AiSelectionRequest) => {\n      latest.current.onAction(request, controls)\n      if (latest.current.closeOnAction) dismiss(true)\n    },\n    [controls, dismiss],\n  )\n\n  const evaluate = React.useCallback(() => {\n    const current = latest.current\n    // Mid-drag the selection is not a decision yet; the menu would chase the\n    // pointer across the paragraph. `pointerup` re-runs this.\n    if (draggingRef.current || latchedRef.current || typeof window === \"undefined\") return\n    if (current.disabled) {\n      setAnchor(null)\n      return\n    }\n\n    const drop = (release: boolean) => {\n      if (release) settledRef.current = null\n      setAnchor(null)\n    }\n\n    const selection = window.getSelection?.()\n    if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return drop(true)\n\n    // The last range is the one the reader just extended. Firefox is the only\n    // engine that builds multi-range selections (Ctrl+drag); there the whole\n    // `selection.toString()` would mix in text from ranges the menu is not\n    // positioned against, so that range's own text is used instead.\n    const range = selection.getRangeAt(selection.rangeCount - 1)\n    const container = range.commonAncestorContainer\n    const scope = scopeRef.current\n    const content = contentRef.current\n    if (!scope || !content) return drop(true)\n    // `commonAncestorContainer` is the deepest node holding both endpoints, so\n    // \"inside the scope\" also proves \"both ends are inside the scope\": a\n    // selection that spills into the page around it resolves to a shared\n    // ancestor and is ignored instead of quietly sending half of it.\n    if (!content.contains(container)) return drop(true)\n    if (inFormField(container)) return drop(true)\n    if (!current.includeEditable && inEditable(container)) return drop(true)\n\n    const raw = selection.rangeCount === 1 ? selection.toString() : range.toString()\n    const { text, truncated, chars } = normalizeSelectionText(raw, current.maxLength)\n    if (text.length < current.minLength) return drop(true)\n\n    const signature = `${scopeId}|${range.startOffset}|${range.endOffset}|${text}`\n    if (settledRef.current === signature) {\n      // Already asked about or dismissed: keep the lock, stay quiet.\n      return drop(false)\n    }\n\n    const box = range.getBoundingClientRect()\n    // A range across nothing but collapsed whitespace has no box to point at.\n    if (box.width === 0 && box.height === 0) return drop(true)\n\n    const scopeBox = scope.getBoundingClientRect()\n    const next: Anchor = {\n      bottom: box.bottom - scopeBox.top,\n      chars,\n      scopeWidth: scopeBox.width,\n      signature,\n      text,\n      top: box.top - scopeBox.top,\n      truncated,\n      viewportTop: box.top,\n      x: box.left + box.width / 2 - scopeBox.left,\n    }\n    setAnchor(prev => (sameAnchor(prev, next) ? prev : next))\n  }, [scopeId])\n\n  // `selectionchange` fires once per arrow key and once per pointer move of a\n  // drag; coalescing to a frame keeps this at one measurement per paint.\n  const schedule = React.useCallback(() => {\n    if (frameRef.current !== null) return\n    frameRef.current = requestAnimationFrame(() => {\n      frameRef.current = null\n      evaluate()\n    })\n  }, [evaluate])\n\n  React.useEffect(() => {\n    const onSelectionChange = () => schedule()\n\n    const onPointerDown = (event: Event) => {\n      const target = event.target\n      // A press on the menu is not the start of a new selection, and must not\n      // tear down the offer the click is about to consume.\n      if (target instanceof Element && target.closest(\"[data-ai-selection-menu]\")) return\n      draggingRef.current = true\n      // Any other press begins a fresh interaction: release the one-shot lock so\n      // the same sentence can be asked about again, and drop the standing offer.\n      latchedRef.current = false\n      settledRef.current = null\n      setAnchor(null)\n    }\n\n    const endDrag = () => {\n      draggingRef.current = false\n      schedule()\n    }\n\n    document.addEventListener(\"selectionchange\", onSelectionChange)\n    document.addEventListener(\"pointerdown\", onPointerDown, true)\n    document.addEventListener(\"pointerup\", endDrag, true)\n    document.addEventListener(\"pointercancel\", endDrag, true)\n    // A drag released outside the window never delivers pointerup; without this\n    // the gate stays shut and no selection ever produces an offer again.\n    window.addEventListener(\"blur\", endDrag)\n\n    return () => {\n      document.removeEventListener(\"selectionchange\", onSelectionChange)\n      document.removeEventListener(\"pointerdown\", onPointerDown, true)\n      document.removeEventListener(\"pointerup\", endDrag, true)\n      document.removeEventListener(\"pointercancel\", endDrag, true)\n      window.removeEventListener(\"blur\", endDrag)\n      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)\n      frameRef.current = null\n    }\n  }, [schedule])\n\n  // Escape dismisses the offer without touching the highlight: the reader said\n  // \"not this\", not \"unselect\". Events from inside the menu are the toolbar's —\n  // there Escape has to close a submenu or the question field first.\n  //\n  // Capture, not bubble: the toolbar's own Escape handler unmounts the submenu\n  // (or the question field) that the press landed in, and a framework that\n  // delegates its listeners to the document runs that handler first. By the time\n  // a bubble-phase listener saw the event its target would already be detached,\n  // \"is it inside the menu?\" would answer no, and closing a submenu would tear\n  // down the whole offer. Capture asks the question while the DOM still stands.\n  React.useEffect(() => {\n    if (!anchor) return\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return\n      const target = event.target\n      if (target instanceof Element && target.closest(\"[data-ai-selection-menu]\")) return\n      dismiss(true)\n    }\n    document.addEventListener(\"keydown\", onKeyDown, true)\n    return () => document.removeEventListener(\"keydown\", onKeyDown, true)\n  }, [anchor, dismiss])\n\n  // The geometry was measured once. In a chat the answer above can still be\n  // streaming, so the selected paragraph moves without any scroll or resize —\n  // re-measure whenever the scope itself reflows.\n  React.useEffect(() => {\n    const scope = scopeRef.current\n    if (!anchor || !scope || typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(() => schedule())\n    observer.observe(scope)\n    return () => observer.disconnect()\n  }, [anchor, schedule])\n\n  // `disabled` is honoured by hiding, not by an effect that clears state: a scope\n  // that goes quiet mid-stream must stop rendering the offer in the same commit.\n  // The stale anchor is dropped by the next evaluation.\n  const open = anchor !== null && !disabled && actions.length > 0\n  const mounted = React.useRef(false)\n  React.useEffect(() => {\n    if (!mounted.current) {\n      mounted.current = true\n      return\n    }\n    latest.current.onOpenChange?.(open)\n  }, [open])\n\n  return (\n    <div className={cn(\"relative\", className)} data-ai-selection-scope=\"\" ref={setScopeRef} {...rest}>\n      {/* The menu is a sibling of the text, never inside it, so reading the\n          selection can never pick up the word \"Summarize\" from the row that is\n          floating over it. */}\n      <div ref={contentRef}>{children}</div>\n\n      {open && anchor ? (\n        <SelectionToolbar\n          actions={actions}\n          anchor={anchor}\n          // A new range is a new offer: remounting resets the roving index, any\n          // open submenu and a half-typed question in one line.\n          key={anchor.signature}\n          label={label}\n          onDismiss={dismiss}\n          onLatch={latch}\n          onRequest={handleRequest}\n          pending={pending}\n          placement={placement}\n        />\n      ) : null}\n\n      {/* Sequential focus navigation cannot reach a floating row reliably, so\n          the first Tab is captured below; this is how a screen reader learns\n          the offer is there. The wording is constant, so it is announced once\n          per offer instead of on every keystroke of a Shift+Arrow selection. */}\n      {open ? (\n        <span aria-atomic=\"true\" className=\"sr-only\" role=\"status\">\n          {`${label} actions are available for the selected text. Press Tab to reach them, Escape to dismiss.`}\n        </span>\n      ) : null}\n    </div>\n  )\n})\n\nAiSelectionMenu.displayName = \"AiSelectionMenu\"\n\n/* -------------------------------------------------------------------------- *\n * Floating row\n * -------------------------------------------------------------------------- */\n\nconst KEYFRAMES = `@keyframes zyeon-asm-in{from{opacity:0;transform:scale(0.97)}}`\n\nconst itemClass = cn(\n  \"inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md px-2 text-xs font-medium whitespace-nowrap\",\n  \"transition-colors motion-reduce:transition-none\",\n  \"hover:bg-accent hover:text-accent-foreground\",\n  \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n  \"aria-expanded:bg-accent aria-expanded:text-accent-foreground\",\n  \"aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n)\n\nfunction IconSlot({ children }: { children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-3.5 [&_svg]:shrink-0\"\n    >\n      {children}\n    </span>\n  )\n}\n\ninterface SelectionToolbarProps {\n  actions: AiSelectionAction[]\n  anchor: Anchor\n  label: string\n  pending: boolean\n  placement: \"auto\" | \"top\" | \"bottom\"\n  onDismiss: (lock: boolean) => void\n  onLatch: () => void\n  onRequest: (request: AiSelectionRequest) => void\n}\n\nfunction SelectionToolbar({\n  actions,\n  anchor,\n  label,\n  pending,\n  placement: placementPref,\n  onDismiss,\n  onLatch,\n  onRequest,\n}: SelectionToolbarProps) {\n  const wrapRef = React.useRef<HTMLDivElement | null>(null)\n  const cardRef = React.useRef<HTMLDivElement | null>(null)\n  const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([])\n  const submenuRef = React.useRef<HTMLDivElement | null>(null)\n  const optionRefs = React.useRef<(HTMLButtonElement | null)[]>([])\n  const inputRef = React.useRef<HTMLInputElement | null>(null)\n  const returnFocusRef = React.useRef<HTMLElement | null>(null)\n\n  const [active, setActive] = React.useState(0)\n  /** The row has been entered from the keyboard: it now owns focus. */\n  const [entered, setEntered] = React.useState(false)\n  const [openIndex, setOpenIndex] = React.useState<number | null>(null)\n  const [optionIndex, setOptionIndex] = React.useState(0)\n  const [askIndex, setAskIndex] = React.useState<number | null>(null)\n  const [query, setQuery] = React.useState(\"\")\n\n  /**\n   * Which items the arrows and the roving tab stop travel over. `pending` is\n   * deliberately NOT folded in: a busy row is temporarily unavailable, not\n   * restructured, and collapsing this list mid-request would move the tab stop\n   * out from under whoever is standing on it and then throw it back when the\n   * answer lands. Only `action.disabled` — a standing decision — is skipped.\n   */\n  const enabled = React.useMemo(\n    () => actions.map((action, index) => (action.disabled ? -1 : index)).filter(index => index >= 0),\n    [actions],\n  )\n  const roving = enabled.includes(active) ? active : (enabled[0] ?? -1)\n  const askAction = askIndex === null ? null : (actions[askIndex] ?? null)\n  const openAction = openIndex === null ? null : (actions[openIndex] ?? null)\n  const openOptions = openAction?.options ?? null\n\n  /**\n   * Above or below — decided once per offer, from the height this menu can reach\n   * rather than the height it has right now. Measuring the live card would flip\n   * the side the moment the question field opens, throwing the whole row across\n   * the selection mid-interaction; reserving the question row up front keeps the\n   * decision stable and still guarantees the expanded menu clears the viewport\n   * edge. The row is bottom-anchored when it sits on top, so growing downwards\n   * in the DOM grows upwards on screen and the anchor never drifts.\n   */\n  const reserved =\n    ROW_HEIGHT + (actions.some(action => action.ask) ? ASK_ROW_HEIGHT : 0) + (anchor.truncated ? NOTE_ROW_HEIGHT : 0)\n  const placement =\n    placementPref === \"auto\"\n      ? anchor.viewportTop - reserved - GAP >= VIEWPORT_MARGIN\n        ? \"top\"\n        : \"bottom\"\n      : placementPref\n\n  /**\n   * Clamp horizontally once the row has a width, so a selection at the very end\n   * of a line cannot hang outside the scope. When the row is wider than the\n   * scope there is nothing to clamp to and it centres instead.\n   */\n  React.useLayoutEffect(() => {\n    const node = wrapRef.current\n    if (!node) return\n    const half = node.offsetWidth / 2\n    const min = EDGE_MARGIN + half\n    const max = anchor.scopeWidth - EDGE_MARGIN - half\n    node.style.left = `${max < min ? anchor.scopeWidth / 2 : Math.min(Math.max(anchor.x, min), max)}px`\n  }, [anchor, askIndex, pending])\n\n  /** Align the submenu with its trigger, then pull it back inside the card. */\n  React.useLayoutEffect(() => {\n    if (openIndex === null) return\n    const panel = submenuRef.current\n    const trigger = itemRefs.current[openIndex]\n    const card = cardRef.current\n    if (!panel || !trigger || !card) return\n    const max = Math.max(0, card.offsetWidth - panel.offsetWidth)\n    panel.style.left = `${Math.min(Math.max(trigger.offsetLeft, 0), max)}px`\n  }, [openIndex])\n\n  React.useEffect(() => {\n    if (!entered || openIndex !== null || askIndex !== null) return\n    itemRefs.current[roving]?.focus()\n  }, [entered, roving, openIndex, askIndex])\n\n  React.useEffect(() => {\n    if (openIndex === null) return\n    optionRefs.current[optionIndex]?.focus()\n  }, [openIndex, optionIndex])\n\n  React.useEffect(() => {\n    if (askIndex === null) return\n    inputRef.current?.focus()\n  }, [askIndex])\n\n  /**\n   * The keyboard path in. A floating row is not in the reading order next to the\n   * selection in every engine, so the FIRST Tab after an offer appears is\n   * captured and routed into the row. Exactly once per offer: hijacking every\n   * Tab from outside would bounce focus back in the moment the reader tabs out,\n   * turning a floating row into a focus trap.\n   */\n  const hijackedRef = React.useRef(false)\n  React.useEffect(() => {\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Tab\" || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return\n      if (hijackedRef.current || enabled.length === 0) return\n      const card = cardRef.current\n      const target = event.target\n      if (card && target instanceof Node && card.contains(target)) return\n      event.preventDefault()\n      hijackedRef.current = true\n      returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null\n      onLatch()\n      setActive(enabled[0])\n      setEntered(true)\n    }\n    document.addEventListener(\"keydown\", onKeyDown)\n    return () => document.removeEventListener(\"keydown\", onKeyDown)\n  }, [enabled, onLatch])\n\n  const restoreFocus = React.useCallback(() => {\n    const node = returnFocusRef.current\n    if (node && node.isConnected) node.focus()\n  }, [])\n\n  const fire = React.useCallback(\n    (action: AiSelectionAction, extra: { option?: string; query?: string }) => {\n      onRequest({\n        chars: anchor.chars,\n        option: extra.option,\n        query: extra.query,\n        selectionText: anchor.text,\n        truncated: anchor.truncated,\n        type: action.type,\n      })\n      setOpenIndex(null)\n      setAskIndex(null)\n      setQuery(\"\")\n    },\n    [anchor, onRequest],\n  )\n\n  const activate = React.useCallback(\n    (index: number, viaKeyboard: boolean) => {\n      const action = actions[index]\n      if (!action || action.disabled || pending) return\n      onLatch()\n      setActive(index)\n      if (viaKeyboard) setEntered(true)\n      if (action.options && action.options.length > 0) {\n        setAskIndex(null)\n        setOpenIndex(current => (current === index ? null : index))\n        setOptionIndex(0)\n        return\n      }\n      if (action.ask) {\n        setOpenIndex(null)\n        setAskIndex(current => (current === index ? null : index))\n        return\n      }\n      setOpenIndex(null)\n      setAskIndex(null)\n      fire(action, {})\n    },\n    [actions, fire, onLatch, pending],\n  )\n\n  /** Move the roving tab stop. Any open submenu or question field closes with it. */\n  const focusAt = React.useCallback((index: number) => {\n    setActive(index)\n    setEntered(true)\n    setOpenIndex(null)\n    setAskIndex(null)\n  }, [])\n\n  const move = React.useCallback(\n    (delta: number) => {\n      if (enabled.length === 0) return\n      const at = enabled.indexOf(active)\n      const base = at === -1 ? (delta > 0 ? -1 : 0) : at\n      focusAt(enabled[(base + delta + enabled.length) % enabled.length])\n    },\n    [active, enabled, focusAt],\n  )\n\n  const handleRowKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    switch (event.key) {\n      case \"ArrowRight\":\n        event.preventDefault()\n        move(1)\n        break\n      case \"ArrowLeft\":\n        event.preventDefault()\n        move(-1)\n        break\n      case \"Home\":\n        if (enabled.length === 0) break\n        event.preventDefault()\n        focusAt(enabled[0])\n        break\n      case \"End\":\n        if (enabled.length === 0) break\n        event.preventDefault()\n        focusAt(enabled[enabled.length - 1])\n        break\n      case \"ArrowDown\": {\n        const action = actions[roving]\n        if (!action?.options?.length && !action?.ask) break\n        event.preventDefault()\n        activate(roving, true)\n        break\n      }\n      case \"Escape\":\n        event.preventDefault()\n        onDismiss(true)\n        restoreFocus()\n        break\n      default:\n        break\n    }\n  }\n\n  const handleSubmenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>, options: AiSelectionOption[]) => {\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        setOptionIndex(current => (current + 1) % options.length)\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        setOptionIndex(current => (current - 1 + options.length) % options.length)\n        break\n      case \"Home\":\n        event.preventDefault()\n        setOptionIndex(0)\n        break\n      case \"End\":\n        event.preventDefault()\n        setOptionIndex(options.length - 1)\n        break\n      case \"Escape\":\n      case \"ArrowLeft\":\n        // Back to the trigger, offer still standing: closing the submenu is not\n        // the same decision as declining the menu.\n        event.preventDefault()\n        event.stopPropagation()\n        setOpenIndex(null)\n        setEntered(true)\n        break\n      default:\n        break\n    }\n  }\n\n  const submitAsk = () => {\n    const value = query.trim()\n    if (!askAction || !value || pending) return\n    fire(askAction, { query: value })\n  }\n\n  return (\n    <div\n      className={cn(\"absolute z-30 -translate-x-1/2\", placement === \"top\" && \"-translate-y-full\")}\n      data-ai-selection-menu=\"\"\n      data-placement={placement}\n      // Latch on the way in, before the browser does anything with the press.\n      onFocusCapture={onLatch}\n      onMouseDown={event => {\n        onLatch()\n        const target = event.target\n        // The default action of a mousedown is to move the caret, which collapses\n        // the very selection this row exists for. The question field is the one\n        // control that needs that default: it has to take focus and a caret.\n        if (target instanceof Element && target.closest(\"input, textarea\")) return\n        event.preventDefault()\n      }}\n      ref={wrapRef}\n      style={{\n        left: `${anchor.x}px`,\n        top: `${placement === \"top\" ? anchor.top - GAP : anchor.bottom + GAP}px`,\n      }}\n    >\n      <style href=\"zyeon-ai-selection-menu\" precedence=\"medium\">\n        {KEYFRAMES}\n      </style>\n\n      <div\n        aria-busy={pending || undefined}\n        className={cn(\n          \"relative flex w-max max-w-[min(28rem,calc(100vw-1.5rem))] flex-col\",\n          \"rounded-lg border bg-popover text-popover-foreground shadow-md\",\n          \"[animation:zyeon-asm-in_140ms_ease-out] motion-reduce:[animation:none]\",\n        )}\n        ref={cardRef}\n      >\n        <div className=\"flex flex-wrap items-center gap-1 p-1\">\n          <span className=\"flex items-center gap-1.5 pr-0.5 pl-1.5 text-xs font-medium text-muted-foreground\">\n            {pending ? (\n              <Loader2 aria-hidden=\"true\" className=\"size-3.5 animate-spin motion-reduce:animate-none\" />\n            ) : (\n              <Sparkles aria-hidden=\"true\" className=\"size-3.5 text-primary\" />\n            )}\n            {pending ? \"Working…\" : label}\n          </span>\n          <span aria-hidden=\"true\" className=\"h-4 w-px bg-border\" />\n\n          <div\n            aria-label={`${label} actions for the selected text`}\n            aria-orientation=\"horizontal\"\n            className=\"flex flex-wrap items-center gap-0.5\"\n            onKeyDown={handleRowKeyDown}\n            role=\"toolbar\"\n          >\n            {actions.map((action, index) => {\n              const hasOptions = Boolean(action.options && action.options.length > 0)\n              const isOpen = openIndex === index\n              const isAsking = askIndex === index\n              return (\n                <button\n                  // aria-disabled, never the native attribute: when the consumer\n                  // flips `pending` the whole row goes unavailable in one commit,\n                  // and `disabled` would blur whichever item the reader just\n                  // pressed, dropping focus to <body> mid-request. `activate`\n                  // re-checks the same condition, so the item stays focusable and\n                  // readable while refusing to fire.\n                  aria-disabled={action.disabled || pending || undefined}\n                  aria-expanded={hasOptions || action.ask ? isOpen || isAsking : undefined}\n                  aria-haspopup={hasOptions ? \"menu\" : undefined}\n                  className={itemClass}\n                  key={action.type}\n                  onClick={() => activate(index, false)}\n                  ref={node => {\n                    itemRefs.current[index] = node\n                  }}\n                  tabIndex={index === roving ? 0 : -1}\n                  type=\"button\"\n                >\n                  {action.icon ? <IconSlot>{action.icon}</IconSlot> : null}\n                  {action.label}\n                  {hasOptions ? (\n                    <ChevronDown\n                      aria-hidden=\"true\"\n                      className={cn(\n                        \"size-3 opacity-60 transition-transform motion-reduce:transition-none\",\n                        isOpen && \"rotate-180\",\n                      )}\n                    />\n                  ) : null}\n                </button>\n              )\n            })}\n          </div>\n        </div>\n\n        {askAction ? (\n          <div className=\"flex items-center gap-1 border-t p-1\">\n            <input\n              // aria-disabled + readOnly rather than the native attribute: the\n              // reader submits this field from inside it, and `disabled` would\n              // blur the box they are standing in the moment the request starts.\n              aria-disabled={pending || undefined}\n              aria-label=\"Ask about the selected text\"\n              className={cn(\n                \"h-7 min-w-0 flex-1 rounded-md bg-transparent px-1.5 text-xs\",\n                \"placeholder:text-muted-foreground\",\n                \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                \"aria-disabled:opacity-50\",\n              )}\n              onChange={event => setQuery(event.target.value)}\n              onKeyDown={event => {\n                // An IME candidate window swallows Enter first; submitting here\n                // would send half a Japanese word.\n                if (event.key === \"Enter\" && !event.nativeEvent.isComposing) {\n                  event.preventDefault()\n                  submitAsk()\n                  return\n                }\n                if (event.key === \"Escape\") {\n                  event.preventDefault()\n                  event.stopPropagation()\n                  setAskIndex(null)\n                  setEntered(true)\n                }\n              }}\n              placeholder={askAction.askPlaceholder ?? \"Ask anything about this selection…\"}\n              readOnly={pending}\n              ref={inputRef}\n              type=\"text\"\n              value={query}\n            />\n            <button\n              // Same reason as the field it sits next to: `submitAsk` already\n              // refuses an empty or in-flight question, so nothing here needs the\n              // native attribute and its focus-dropping side effect.\n              aria-disabled={pending || query.trim().length === 0 || undefined}\n              aria-label=\"Send question\"\n              className={cn(\n                \"inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md\",\n                \"bg-primary text-primary-foreground transition-opacity motion-reduce:transition-none\",\n                \"hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                \"aria-disabled:pointer-events-none aria-disabled:opacity-40\",\n              )}\n              onClick={submitAsk}\n              type=\"button\"\n            >\n              <CornerDownLeft aria-hidden=\"true\" className=\"size-3.5\" />\n            </button>\n          </div>\n        ) : null}\n\n        {anchor.truncated ? (\n          <p className=\"border-t px-2 py-1 text-[0.6875rem] text-muted-foreground\">\n            {`Sending the first ${anchor.text.length.toLocaleString(\"en-US\")} of ${anchor.chars.toLocaleString(\"en-US\")} selected characters`}\n          </p>\n        ) : null}\n\n        {openAction && openOptions && openOptions.length > 0 ? (\n          <div\n            aria-label={openAction.label}\n            className={cn(\n              \"absolute z-40 flex min-w-44 flex-col gap-0.5 rounded-lg border p-1\",\n              \"bg-popover text-popover-foreground shadow-md\",\n              \"[animation:zyeon-asm-in_120ms_ease-out] motion-reduce:[animation:none]\",\n              placement === \"top\" ? \"bottom-full mb-1\" : \"top-full mt-1\",\n            )}\n            onKeyDown={event => handleSubmenuKeyDown(event, openOptions)}\n            ref={submenuRef}\n            role=\"menu\"\n          >\n            {openOptions.map((option, index) => (\n              <button\n                className={cn(\n                  \"flex w-full cursor-pointer flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-left\",\n                  \"transition-colors motion-reduce:transition-none\",\n                  \"hover:bg-accent hover:text-accent-foreground\",\n                  \"focus:bg-accent focus:text-accent-foreground focus:outline-none\",\n                )}\n                key={option.value}\n                onClick={() => fire(openAction, { option: option.value })}\n                ref={node => {\n                  optionRefs.current[index] = node\n                }}\n                role=\"menuitem\"\n                tabIndex={index === optionIndex ? 0 : -1}\n                type=\"button\"\n              >\n                <span className=\"text-xs font-medium\">{option.label}</span>\n                {option.description ? (\n                  <span className=\"text-[0.6875rem] text-muted-foreground\">{option.description}</span>\n                ) : null}\n              </button>\n            ))}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  )\n}\n\nexport default AiSelectionMenu\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}