{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "response-actions",
  "type": "registry:component",
  "title": "Response actions",
  "description": "Selectable subtle 44px icon controls for copying, reading aloud, rating through a combined feedback menu, and sharing an assistant response.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/button.json",
    "https://vlak.dev/r/dropdown-menu.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/response-actions.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { useMergedRefs } from \"./merge-refs\";\nimport { useOverlayPosition } from \"./use-overlay-position\";\nimport { MenuPanel, type MenuCloseReason } from \"./dropdown-menu\";\nimport { Button } from \"./button\";\n\nexport type ResponseFeedback = \"positive\" | \"negative\" | null;\nexport type ResponseAction = \"copy\" | \"read\" | \"feedback\" | \"share\";\n\nexport interface ResponseActionsProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Plain text used for copying, narration, and the default share action. */\n  text: string;\n  /** Built-in controls in display and keyboard order. Omit for all four; duplicates are ignored. */\n  actions?: readonly ResponseAction[];\n  feedback?: ResponseFeedback;\n  defaultFeedback?: ResponseFeedback;\n  /** A rejected promise preserves the previous selection and allows retry. */\n  onFeedback?: (value: ResponseFeedback) => void | Promise<void>;\n  /** Overrides native sharing. Without it, unsupported browsers copy the text. */\n  onShare?: () => void | Promise<void>;\n  /** Reports this response's narration state for an avatar or other presentation. */\n  onReadingChange?: (reading: boolean) => void;\n}\n\nconst styles = stylex.create({\n  root: { display: \"flex\", flexWrap: \"wrap\", alignItems: \"center\", gap: 0, minWidth: 0, color: vlak.gray },\n  selected: { color: { default: vlak.ink, [mq.forcedColors]: \"HighlightText\" }, backgroundColor: { default: vlak.tableAlt, [mq.forcedColors]: \"Highlight\" } },\n  icon: { display: \"block\", width: 18, height: 18, flexShrink: 0, pointerEvents: \"none\" },\n  status: { color: vlak.gray, fontSize: \"0.8125rem\", lineHeight: 1.45, overflowWrap: \"anywhere\" },\n  menu: { boxSizing: \"border-box\", position: \"fixed\", zIndex: vlak.zFloat, width: \"13rem\", margin: 0, padding: \"0.25rem\", borderWidth: vlak.hairline, borderStyle: \"solid\", borderColor: { default: vlak.controlBorder, [mq.forcedColors]: \"CanvasText\" }, borderRadius: vlak.radiusSm, backgroundColor: vlak.paper, color: vlak.ink },\n});\n\ntype ActionIcon = \"copy\" | \"read\" | \"stop\" | \"feedback\" | \"share\";\n\nfunction ActionGlyph({ name }: { name: ActionIcon }) {\n  const icon = rs([\"rs-response-actions-icon\"], styles.icon);\n  return <svg {...icon} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\" focusable=\"false\">\n    {name === \"copy\" && <><rect x=\"8\" y=\"8\" width=\"12\" height=\"13\" rx=\"1\" /><path d=\"M16 8V3H4v13h4\" /></>}\n    {name === \"read\" && <><path d=\"m11 4-6 5H2v6h3l6 5V4Z\" /><path d=\"M15 8a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14\" /></>}\n    {name === \"stop\" && <rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"1\" />}\n    {name === \"feedback\" && <><path transform=\"translate(1 0)\" d=\"M1 7h3v7H1zM4 7l3-5c1.5 0 2 1 1 4h4a1 1 0 0 1 1 1l-1.5 6a1 1 0 0 1-1 .8H4\" /><path transform=\"translate(-1 0)\" d=\"M23 17h-3v-7h3zM20 17l-3 5c-1.5 0-2-1-1-4h-4a1 1 0 0 1-1-1l1.5-6a1 1 0 0 1 1-.8H20\" /></>}\n    {name === \"share\" && <path d=\"M12 16V2m-5 5 5-5 5 5M5 12H3v10h18V12h-2\" />}\n  </svg>;\n}\n\n/** Compact message actions. Speech and sharing run only after a button activation. */\nexport const ResponseActions = React.forwardRef<HTMLDivElement, ResponseActionsProps>(function ResponseActions({\n  text, actions = [\"copy\", \"read\", \"feedback\", \"share\"], feedback, defaultFeedback = null, onFeedback, onShare, onReadingChange, className, style, children, \"aria-label\": label = \"Response actions\", ...props\n}, ref) {\n  const actionOrder = [...new Set(actions)];\n  const hasRead = actionOrder.includes(\"read\");\n  const hasFeedback = actionOrder.includes(\"feedback\");\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const mergedRef = useMergedRefs(rootRef, ref);\n  const [innerFeedback, setInnerFeedback] = React.useState<ResponseFeedback>(defaultFeedback);\n  const selected = feedback === undefined ? innerFeedback : feedback;\n  const [busy, setBusy] = React.useState(false);\n  const [message, setMessage] = React.useState(\"\");\n  const [canRead, setCanRead] = React.useState(false);\n  const [reading, setReading] = React.useState(false);\n  const locked = React.useRef(false);\n  const version = React.useRef(0);\n  const utterance = React.useRef<SpeechSynthesisUtterance | null>(null);\n  const readingRef = React.useRef(false);\n  const readingCallback = React.useRef(onReadingChange);\n  readingCallback.current = onReadingChange;\n  const feedbackId = React.useId();\n  const feedbackTrigger = React.useRef<HTMLButtonElement>(null);\n  const feedbackPanel = React.useRef<HTMLDivElement>(null);\n  const [feedbackOpen, setFeedbackOpen] = React.useState(false);\n  const [initialChoice, setInitialChoice] = React.useState<\"first\" | \"last\">(\"first\");\n  const placement = useOverlayPosition(feedbackOpen, feedbackPanel, feedbackTrigger, undefined, \"bottom\", { matchAnchorWidth: false });\n\n  React.useEffect(() => {\n    if (!feedbackOpen) return;\n    const outside = (event: PointerEvent) => {\n      if (!feedbackTrigger.current?.contains(event.target as Node) && !feedbackPanel.current?.contains(event.target as Node)) setFeedbackOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", outside);\n    return () => document.removeEventListener(\"pointerdown\", outside);\n  }, [feedbackOpen]);\n\n  function closeFeedback(reason: MenuCloseReason) {\n    setFeedbackOpen(false);\n    if (reason !== \"outside\") feedbackTrigger.current?.focus();\n  }\n\n  function openFeedback(at: \"first\" | \"last\" = \"first\") {\n    if (locked.current) return;\n    setInitialChoice(at);\n    setFeedbackOpen(true);\n  }\n\n  function feedbackKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\" || event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      if (feedbackOpen) closeFeedback(\"escape\");\n      else openFeedback(event.key === \"ArrowUp\" ? \"last\" : \"first\");\n    } else if (event.key === \"Escape\" && feedbackOpen) {\n      event.preventDefault();\n      closeFeedback(\"escape\");\n    }\n  }\n\n  function updateReading(value: boolean, updateState = true) {\n    if (updateState) setReading(value);\n    if (readingRef.current === value) return;\n    readingRef.current = value;\n    readingCallback.current?.(value);\n  }\n\n  function stopReading(updateState = true) {\n    const owned = utterance.current;\n    if (!owned) return;\n    utterance.current = null;\n    owned.onend = null;\n    owned.onerror = null;\n    // The platform exposes one shared queue. Only cancel while our own utterance is pending or active.\n    window.speechSynthesis.cancel();\n    updateReading(false, updateState);\n  }\n\n  React.useEffect(() => {\n    setCanRead(typeof window.speechSynthesis?.speak === \"function\" && typeof window.SpeechSynthesisUtterance === \"function\");\n  }, []);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: Removing a control closes its owned interaction, without restarting on unrelated renders.\n  React.useEffect(() => {\n    if (!hasRead) stopReading();\n    if (!hasFeedback && feedbackOpen) {\n      setFeedbackOpen(false);\n      rootRef.current?.querySelector<HTMLButtonElement>(\"button:not(:disabled)\")?.focus();\n    }\n  }, [hasRead, hasFeedback]);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: Replacing message text invalidates its pending actions and narration.\n  React.useEffect(() => {\n    version.current++;\n    locked.current = false;\n    setBusy(false);\n    setMessage(\"\");\n    setReading(false);\n    if (feedbackOpen) feedbackTrigger.current?.focus();\n    setFeedbackOpen(false);\n    return () => { version.current++; stopReading(false); };\n  }, [text]);\n\n  async function perform(action: () => Promise<string>, errorMessage: string) {\n    if (locked.current) return;\n    locked.current = true;\n    const request = ++version.current;\n    setBusy(true);\n    setMessage(\"\");\n    setFeedbackOpen(false);\n    try {\n      const result = await action();\n      if (request === version.current) setMessage(result);\n    } catch {\n      if (request === version.current) setMessage(errorMessage);\n    } finally {\n      if (request === version.current) { locked.current = false; setBusy(false); }\n    }\n  }\n\n  async function copyText() {\n    if (!navigator.clipboard?.writeText) throw new Error(\"Clipboard unavailable\");\n    await navigator.clipboard.writeText(text);\n  }\n\n  function copy() {\n    void perform(async () => { await copyText(); return \"Copied\"; }, \"Could not copy. Try again.\");\n  }\n\n  function rate(value: Exclude<ResponseFeedback, null>) {\n    const next = selected === value ? null : value;\n    const request = version.current + 1;\n    void perform(async () => {\n      await onFeedback?.(next);\n      if (request === version.current && feedback === undefined) setInnerFeedback(next);\n      return next === \"positive\" ? \"Marked as helpful\" : next === \"negative\" ? \"Marked as unhelpful\" : \"Feedback cleared\";\n    }, \"Could not save feedback. Try again.\");\n  }\n\n  function share() {\n    void perform(async () => {\n      if (onShare) { await onShare(); return \"Shared\"; }\n      if (typeof navigator.share === \"function\") {\n        try { await navigator.share({ text }); return \"Shared\"; }\n        catch (error) { if (typeof error === \"object\" && error !== null && \"name\" in error && error.name === \"AbortError\") return \"\"; throw error; }\n      }\n      await copyText();\n      return \"Copied response to share\";\n    }, \"Could not share. Try again.\");\n  }\n\n  function narrate() {\n    if (utterance.current) { stopReading(); setMessage(\"Reading stopped\"); return; }\n    if (!canRead || !text) return;\n    const speech = window.speechSynthesis;\n    if (speech.speaking || speech.pending) { setMessage(\"Another reading is in progress. Try again when it finishes.\"); return; }\n    const next = new window.SpeechSynthesisUtterance(text);\n    utterance.current = next;\n    next.onend = () => {\n      if (utterance.current !== next) return;\n      utterance.current = null;\n      updateReading(false);\n      setMessage(\"Reading finished\");\n    };\n    next.onerror = () => {\n      if (utterance.current !== next) return;\n      utterance.current = null;\n      updateReading(false);\n      setMessage(\"Could not read aloud. Try again.\");\n    };\n    updateReading(true);\n    setMessage(\"\");\n    try { speech.speak(next); }\n    catch { utterance.current = null; updateReading(false); setMessage(\"Could not read aloud. Try again.\"); }\n  }\n\n  const root = rs([\"rs-response-actions-bar\", className], styles.root);\n  const feedbackButton = rs([selected !== null && \"rs-response-actions-selected\"], selected !== null && styles.selected);\n  const menu = rs([\"rs-response-actions-menu\"], styles.menu);\n  const status = rs([\"rs-response-actions-status\"], styles.status);\n  const readingLabel = reading ? \"Stop reading\" : \"Read aloud\";\n  const controls: Record<ResponseAction, React.ReactNode> = {\n    copy: <Button variant=\"subtle\" size=\"icon\" aria-label=\"Copy response\" title=\"Copy response\" disabled={busy || !text} onClick={copy}><ActionGlyph name=\"copy\" /></Button>,\n    read: <Button variant=\"subtle\" size=\"icon\" aria-label={readingLabel} title={canRead ? readingLabel : \"Read aloud is unavailable in this browser\"} disabled={!canRead || !text} onClick={narrate}><ActionGlyph name={reading ? \"stop\" : \"read\"} /></Button>,\n    feedback: <><Button {...feedbackButton} variant=\"subtle\" size=\"icon\" ref={feedbackTrigger} id={`${feedbackId}-trigger`} aria-label=\"Rate response\" title={selected === null ? \"Rate response\" : selected === \"positive\" ? \"Rate response: Helpful\" : \"Rate response: Unhelpful\"} aria-haspopup=\"menu\" aria-expanded={feedbackOpen} aria-controls={feedbackOpen ? `${feedbackId}-menu` : undefined} aria-disabled={busy || undefined} data-feedback={selected ?? \"none\"} onClick={() => feedbackOpen ? closeFeedback(\"select\") : openFeedback()} onKeyDown={feedbackKeyDown}><ActionGlyph name=\"feedback\" /></Button>\n    {feedbackOpen && hasFeedback && <MenuPanel id={`${feedbackId}-menu`} panelRef={feedbackPanel} labelledBy={`${feedbackId}-trigger`} initial={initialChoice} className={menu.className} style={{ ...menu.style, ...placement }} onClose={closeFeedback} items={[\n      { label: \"Helpful response\", checked: selected === \"positive\", disabled: busy, onSelect: () => rate(\"positive\") },\n      { label: \"Unhelpful response\", checked: selected === \"negative\", disabled: busy, onSelect: () => rate(\"negative\") },\n    ]} />}</>,\n    share: <Button variant=\"subtle\" size=\"icon\" aria-label=\"Share response\" title=\"Share response\" disabled={busy || !text} onClick={share}><ActionGlyph name=\"share\" /></Button>,\n  };\n  return <div role=\"group\" aria-label={label} {...props} ref={mergedRef} className={root.className} style={{ ...root.style, ...style }}>\n    {actionOrder.map(action => <React.Fragment key={action}>{controls[action]}</React.Fragment>)}\n    {children}\n    <span {...status} role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">{message}</span>\n  </div>;\n});\n",
      "type": "registry:component",
      "target": "components/vlak/response-actions.tsx"
    },
    {
      "path": "vlak/use-overlay-position.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/** Keep lightweight overlays inside the visual viewport. The native top layer avoids clipping. */\nexport function useOverlayPosition(\n  open: boolean,\n  panel: React.RefObject<HTMLElement | null>,\n  anchor: React.RefObject<HTMLElement | null>,\n  point?: { x: number; y: number } | null,\n  placement: \"bottom\" | \"inline-end\" = \"bottom\",\n  options?: { popover?: \"auto\" | \"manual\"; edge?: number; matchAnchorWidth?: boolean },\n) {\n  const [position, setPosition] = React.useState<React.CSSProperties>({ visibility: \"hidden\" });\n  React.useLayoutEffect(() => {\n    const element = panel.current;\n    if (!open || !element) return;\n    const native = typeof element.showPopover === \"function\";\n    if (native) {\n      element.setAttribute(\"popover\", options?.popover ?? \"manual\");\n      if (!element.matches(\":popover-open\")) element.showPopover();\n    }\n    const place = () => {\n      const viewport = window.visualViewport;\n      const edge = Math.max(0, options?.edge ?? 8);\n      const leftEdge = (viewport?.offsetLeft ?? 0) + edge;\n      const topEdge = (viewport?.offsetTop ?? 0) + edge;\n      const viewportWidth = viewport?.width ?? window.innerWidth;\n      const viewportHeight = viewport?.height ?? window.innerHeight;\n      const rightEdge = leftEdge + viewportWidth - edge * 2;\n      const bottomEdge = topEdge + viewportHeight - edge * 2;\n      const target = anchor.current?.getBoundingClientRect();\n      const paint = getComputedStyle(element);\n      const borderX = (Number.parseFloat(paint.borderLeftWidth) || 0) + (Number.parseFloat(paint.borderRightWidth) || 0);\n      const borderY = (Number.parseFloat(paint.borderTopWidth) || 0) + (Number.parseFloat(paint.borderBottomWidth) || 0);\n      const matchAnchor = options?.matchAnchorWidth !== false;\n      let panelWidth = element.scrollWidth + borderX;\n      if (!matchAnchor) {\n        // Measure the authored width without the last placement’s viewport clamp.\n        // Restoring it here also keeps resize observation tied to the final layout.\n        const { width, minWidth, maxWidth } = element.style;\n        element.style.width = \"\";\n        element.style.minWidth = \"\";\n        element.style.maxWidth = \"\";\n        panelWidth = element.getBoundingClientRect().width;\n        Object.assign(element.style, { width, minWidth, maxWidth });\n      }\n      const width = Math.min(Math.max(panelWidth, matchAnchor ? target?.width ?? 0 : 0), viewportWidth - edge * 2);\n      const height = Math.min(element.scrollHeight + borderY, viewportHeight - edge * 2);\n      let left = point?.x ?? target?.left ?? leftEdge;\n      let top = point?.y ?? (target ? target.bottom + 6 : topEdge);\n      if (placement === \"inline-end\" && target) {\n        const rtl = getComputedStyle(anchor.current!).direction === \"rtl\";\n        left = rtl ? target.left - width - 4 : target.right + 4;\n        if (left + width > rightEdge) left = target.left - width - 4;\n        if (left < leftEdge) left = target.right + 4;\n        top = target.top;\n      }\n      if (top + height > bottomEdge) {\n        top = placement === \"inline-end\" ? bottomEdge - height : point?.y !== undefined ? point.y - height : target ? target.top - height - 6 : bottomEdge - height;\n      }\n      left = Math.max(leftEdge, Math.min(left, rightEdge - width));\n      top = Math.max(topEdge, Math.min(top, bottomEdge - height));\n      const next: React.CSSProperties = {\n        position: \"fixed\", inset: \"auto\", left, top, margin: 0,\n        minWidth: matchAnchor ? Math.min(target?.width ?? 0, viewportWidth - edge * 2) : 0,\n        maxWidth: viewportWidth - edge * 2, maxHeight: viewportHeight - edge * 2,\n        width, overflow: \"auto\", visibility: \"visible\",\n      };\n      setPosition((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);\n    };\n    place();\n    const observer = typeof ResizeObserver === \"undefined\" ? undefined : new ResizeObserver(place);\n    observer?.observe(element);\n    if (anchor.current) observer?.observe(anchor.current);\n    window.addEventListener(\"resize\", place);\n    window.addEventListener(\"scroll\", place, true);\n    window.visualViewport?.addEventListener(\"resize\", place);\n    window.visualViewport?.addEventListener(\"scroll\", place);\n    return () => {\n      observer?.disconnect();\n      window.removeEventListener(\"resize\", place);\n      window.removeEventListener(\"scroll\", place, true);\n      window.visualViewport?.removeEventListener(\"resize\", place);\n      window.visualViewport?.removeEventListener(\"scroll\", place);\n    };\n  }, [open, panel, anchor, point?.x, point?.y, placement, options?.popover, options?.edge, options?.matchAnchorWidth]);\n  return position;\n}\n",
      "type": "registry:file",
      "target": "components/vlak/use-overlay-position.ts"
    },
    {
      "path": "vlak/styles/response-actions.css",
      "content": "/* ── response-actions: generated from packages/react/src/components/response-actions.tsx ── */\n.rs-response-actions-bar{display:flex;flex-wrap:wrap;align-items:center;gap:0;min-width:0;color:var(--text-secondary)}\n.rs-response-actions-selected{color:var(--text);background-color:var(--table-alt)}\n@media (forced-colors: active){.rs-response-actions-selected{color:HighlightText;background-color:Highlight}}\n.rs-response-actions-icon{display:block;width:18px;height:18px;flex-shrink:0;pointer-events:none}\n.rs-response-actions-status{color:var(--text-secondary);font-size:0.8125rem;line-height:1.45;overflow-wrap:anywhere}\n.rs-response-actions-menu{box-sizing:border-box;position:fixed;z-index:var(--z-float);width:13rem;margin:0;padding:0.25rem;border-width:1px;border-style:solid;border-color:var(--control-border);border-radius:var(--radius-sm);background-color:var(--bg);color:var(--text)}\n@media (forced-colors: active){.rs-response-actions-menu{border-color:CanvasText}}\n",
      "type": "registry:file",
      "target": "styles/vlak/response-actions.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "ai",
      "classes": [
        "rs-response-actions-bar",
        "rs-response-actions-selected",
        "rs-response-actions-icon",
        "rs-response-actions-status",
        "rs-response-actions-menu"
      ],
      "snippet": "<div class=\"rs-response-actions-bar\" role=\"group\" aria-label=\"Response actions\"><button class=\"rs-btn-subtle rs-btn-icon\" type=\"button\" aria-label=\"Copy response\" title=\"Copy response\"><svg class=\"rs-response-actions-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" aria-hidden=\"true\"><rect x=\"8\" y=\"8\" width=\"12\" height=\"13\" rx=\"1\"/><path d=\"M16 8V3H4v13h4\"/></svg></button><button class=\"rs-btn-subtle rs-btn-icon\" type=\"button\" aria-label=\"Read aloud\" title=\"Read aloud\"><svg class=\"rs-response-actions-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" aria-hidden=\"true\"><path d=\"m11 4-6 5H2v6h3l6 5V4ZM15 8a6 6 0 0 1 0 8\"/></svg></button><button class=\"rs-btn-subtle rs-btn-icon\" type=\"button\" aria-label=\"Rate response\" title=\"Rate response\" aria-haspopup=\"menu\" aria-expanded=\"false\"><svg class=\"rs-response-actions-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" aria-hidden=\"true\"><path d=\"M1 7h3v7H1zM4 7l3-5c1.5 0 2 1 1 4h4a1 1 0 0 1 1 1l-1.5 6a1 1 0 0 1-1 .8H4M23 17h-3v-7h3zM20 17l-3 5c-1.5 0-2-1-1-4h-4a1 1 0 0 1-1-1l1.5-6a1 1 0 0 1 1-.8H20\"/></svg></button><button class=\"rs-btn-subtle rs-btn-icon\" type=\"button\" aria-label=\"Share response\" title=\"Share response\"><svg class=\"rs-response-actions-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" aria-hidden=\"true\"><path d=\"M12 16V2m-5 5 5-5 5 5M5 12H3v10h18V12h-2\"/></svg></button><span class=\"rs-response-actions-status\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\"></span></div>",
      "cssOnly": false,
      "registryDependencies": [
        "button",
        "dropdown-menu",
        "vlak-lib"
      ],
      "aliases": [
        "AI response actions",
        "Message toolbar",
        "Read aloud",
        "Message feedback",
        "Thumbs up",
        "Thumbs down"
      ],
      "example": "\"use client\";\nimport { useState } from \"react\";\nimport { Response, ResponseActions, type ResponseAction, type ResponseFeedback } from \"@noorddev/vlak-react\";\n\nconst controls: readonly ResponseAction[] = [\"copy\", \"read\", \"feedback\", \"share\"];\n\nexport function AnswerActions() {\n  const [feedback, setFeedback] = useState<ResponseFeedback>(null);\n  const answer = \"The brief now names one owner and one next step.\";\n  return <Response actions={<ResponseActions text={answer} actions={controls} feedback={feedback} onFeedback={setFeedback} />}>\n    <p>{answer}</p>\n  </Response>;\n}",
      "usage": {
        "use": [
          "Place in the actions slot of Response and supply the same plain text the reader sees.",
          "actions chooses the built-in controls and their display and keyboard order: copy, read, feedback, share. The default includes all four; duplicates are ignored. Add custom controls as children.",
          "Each action composes a subtle icon Button. Muted text turns to ink on hover while the surface stays transparent.",
          "The combined feedback icon opens helpful and unhelpful choices. Selecting the checked choice again clears it.",
          "Use onFeedback to persist positive, negative, or cleared feedback. A rejected promise keeps the previous selection available for retry.",
          "Use feedback for controlled selection or defaultFeedback for an initial local selection.",
          "Supply onShare for an application-owned share flow. Otherwise the browser shares the text or copies it when native sharing is unavailable.",
          "Read aloud uses browser speech only after activation. onReadingChange can coordinate an avatar with narration. Removing read stops owned narration; removing feedback closes its menu.",
          "CSS-only markup supplies named controls; clipboard, speech, the feedback menu, and sharing require React or application code."
        ],
        "avoid": [
          "Adding copyText to the same Response, which would duplicate its copy control.",
          "Assuming local feedback has been stored on a server without supplying onFeedback.",
          "Passing raw markup as text or automatically starting narration when a message appears."
        ]
      },
      "keyboard": [
        {
          "keys": "Tab",
          "does": "Moves through the available copy, narration, feedback, and share controls"
        },
        {
          "keys": "Enter, Space",
          "does": "Activates the focused action or feedback choice; choosing the checked feedback item again clears it"
        },
        {
          "keys": "Arrow Down, Arrow Up",
          "does": "Opens feedback on the first or last choice, then moves between choices"
        },
        {
          "keys": "Home, End",
          "does": "Moves to the first or last feedback choice"
        },
        {
          "keys": "Escape",
          "does": "Closes the feedback menu and returns focus to its trigger"
        },
        {
          "keys": "Tab",
          "does": "Closes the feedback menu and moves focus to the next action"
        }
      ],
      "a11y": [
        "Every icon button has a readable name and title, a 44px target, and a visible focus outline.",
        "One Rate response trigger exposes its expanded menu state. Helpful and unhelpful menu choices expose aria-checked and remain mutually exclusive.",
        "The menu moves focus between choices and returns focus on selection or Escape. Outside interaction dismisses it without stealing focus.",
        "A polite, atomic status region announces completed actions and recoverable errors. Clipboard success is reported only after the write resolves.",
        "Read aloud is disabled when browser speech is unavailable and becomes Stop reading while this response is narrated. Replacing the text or unmounting stops this component's narration.",
        "Pending asynchronous actions prevent duplicate activation. The feedback trigger remains focusable with aria-disabled while saving. Replacing the response text closes its menu and invalidates stale results.",
        "The group accepts native div attributes, children for extra actions, className, style, and a forwarded div ref."
      ]
    }
  }
}
