{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-composer",
  "type": "registry:ui",
  "title": "Message Composer",
  "description": "Grows with its text, takes files and emoji, and turns its mic into send.",
  "categories": [
    "messaging"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/message-composer.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUp, Mic, Paperclip, Trash, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Types and small helpers\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ComposerAttachment = {\n  id: string;\n  name: string;\n  /** Bytes. */\n  size: number;\n  /** MIME type. Images get a thumbnail. */\n  type: string;\n  /** The picked file, when it came from this composer. */\n  file?: File;\n  /** A URL to preview an image. The composer makes an object URL for picked images. */\n  preview?: string;\n  /** 0–1 while uploading. Leave undefined once it's done. */\n  progress?: number;\n  /** Why the upload failed, in a few words. */\n  error?: string;\n};\n\nexport type ComposerMessage = {\n  text: string;\n  attachments: ComposerAttachment[];\n};\n\nexport type VoiceRecording = {\n  blob: Blob;\n  /** An object URL for the blob, ready for an audio element. Revoke it when you're done. */\n  url: string;\n  /** Seconds. */\n  duration: number;\n  /** 48 loudness samples, 0–1, for drawing a waveform without decoding the audio. */\n  peaks: number[];\n};\n\nconst units = [\"B\", \"KB\", \"MB\", \"GB\"];\n/** \"940 KB\", \"2.4 MB\". */\nexport function formatBytes(bytes: number) {\n  let n = bytes;\n  let u = 0;\n  while (n >= 1000 && u < units.length - 1) {\n    n /= 1000;\n    u++;\n  }\n  return `${u === 0 ? n : n < 10 ? n.toFixed(1) : Math.round(n)} ${units[u]}`;\n}\n\nconst clockFormat = (seconds: number) => {\n  const s = Math.max(0, Math.floor(seconds));\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n};\n\nconst splitName = (name: string) => {\n  const dot = name.lastIndexOf(\".\");\n  return dot > 0 && dot > name.length - 8 ? [name.slice(0, dot), name.slice(dot + 1)] : [name, \"\"];\n};\n\nlet seq = 0;\nconst nextId = () => `att-${Date.now().toString(36)}-${(seq++).toString(36)}`;\n\nconst coarseQuery = \"(pointer: coarse)\";\nconst subscribeCoarse = (cb: () => void) => {\n  const mq = window.matchMedia(coarseQuery);\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n};\n\nconst storage = {\n  read(key: string) {\n    try {\n      return window.localStorage.getItem(key) ?? \"\";\n    } catch {\n      return \"\";\n    }\n  },\n  write(key: string, value: string) {\n    try {\n      if (value) window.localStorage.setItem(key, value);\n      else window.localStorage.removeItem(key);\n    } catch {\n      /* Private mode or blocked storage: the draft just isn't kept. */\n    }\n  },\n};\n\n// Inserts through the editing pipeline so the browser's undo still works.\nfunction insertAtCaret(el: HTMLTextAreaElement, text: string) {\n  const start = el.selectionStart;\n  const end = el.selectionEnd;\n  el.focus({ preventScroll: true });\n  el.setSelectionRange(start, end);\n  let ok = false;\n  try {\n    ok = document.execCommand(\"insertText\", false, text);\n  } catch {\n    ok = false;\n  }\n  if (!ok) {\n    el.setRangeText(text, start, end, \"end\");\n    el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n  }\n}\n\nexport const defaultEmojis: { emoji: string; label: string }[] = [\n  { emoji: \"😀\", label: \"Grinning\" },\n  { emoji: \"😂\", label: \"Tears of joy\" },\n  { emoji: \"😊\", label: \"Smiling\" },\n  { emoji: \"😍\", label: \"Heart eyes\" },\n  { emoji: \"🥲\", label: \"Smiling with tear\" },\n  { emoji: \"😅\", label: \"Sweat smile\" },\n  { emoji: \"🤔\", label: \"Thinking\" },\n  { emoji: \"🙃\", label: \"Upside down\" },\n  { emoji: \"😮\", label: \"Surprised\" },\n  { emoji: \"😭\", label: \"Crying\" },\n  { emoji: \"😴\", label: \"Sleeping\" },\n  { emoji: \"🫡\", label: \"Salute\" },\n  { emoji: \"👍\", label: \"Thumbs up\" },\n  { emoji: \"👎\", label: \"Thumbs down\" },\n  { emoji: \"👏\", label: \"Clapping\" },\n  { emoji: \"🙌\", label: \"Raised hands\" },\n  { emoji: \"🙏\", label: \"Thank you\" },\n  { emoji: \"🤝\", label: \"Handshake\" },\n  { emoji: \"💪\", label: \"Flexed arm\" },\n  { emoji: \"👀\", label: \"Eyes\" },\n  { emoji: \"🎉\", label: \"Party popper\" },\n  { emoji: \"🔥\", label: \"Fire\" },\n  { emoji: \"✨\", label: \"Sparkles\" },\n  { emoji: \"💯\", label: \"Hundred\" },\n  { emoji: \"✅\", label: \"Done\" },\n  { emoji: \"❌\", label: \"Cross\" },\n  { emoji: \"⚠️\", label: \"Warning\" },\n  { emoji: \"❤️\", label: \"Heart\" },\n  { emoji: \"☕\", label: \"Coffee\" },\n  { emoji: \"🍕\", label: \"Pizza\" },\n  { emoji: \"🚀\", label: \"Rocket\" },\n  { emoji: \"💡\", label: \"Idea\" },\n  { emoji: \"📌\", label: \"Pin\" },\n  { emoji: \"📎\", label: \"Paperclip\" },\n  { emoji: \"📅\", label: \"Calendar\" },\n  { emoji: \"📈\", label: \"Chart up\" },\n  { emoji: \"🐛\", label: \"Bug\" },\n  { emoji: \"🧪\", label: \"Test tube\" },\n  { emoji: \"🔒\", label: \"Lock\" },\n  { emoji: \"🎯\", label: \"Target\" },\n];\n\n/* -------------------------------------------------------------------------------------------------\n * useVoiceRecorder: microphone, live levels and the finished clip, without any UI\n * -----------------------------------------------------------------------------------------------*/\n\nexport type RecorderState = \"idle\" | \"starting\" | \"recording\";\n\n/** Records from the microphone. Live `levels` (0–1) update about 16 times a second while recording. */\nexport function useVoiceRecorder({ onError }: { onError?: (message: string) => void } = {}) {\n  const [state, setState] = useState<RecorderState>(\"idle\");\n  const [levels, setLevels] = useState<number[]>([]);\n  const [elapsed, setElapsed] = useState(0);\n  const session = useRef<{\n    stream: MediaStream;\n    recorder: MediaRecorder;\n    ctx: AudioContext;\n    chunks: Blob[];\n    all: number[];\n    started: number;\n    raf: number;\n  } | null>(null);\n  const onErrorRef = useRef(onError);\n  useEffect(() => {\n    onErrorRef.current = onError;\n  }, [onError]);\n\n  const teardown = useCallback(() => {\n    const s = session.current;\n    if (!s) return;\n    session.current = null;\n    cancelAnimationFrame(s.raf);\n    s.stream.getTracks().forEach((t) => t.stop());\n    void s.ctx.close().catch(() => {});\n  }, []);\n\n  useEffect(() => teardown, [teardown]);\n\n  const start = useCallback(async () => {\n    if (session.current) return;\n    setState(\"starting\");\n    setLevels([]);\n    setElapsed(0);\n    try {\n      if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === \"undefined\") throw new DOMException(\"\", \"NotSupportedError\");\n      const stream = await navigator.mediaDevices.getUserMedia({\n        audio: { echoCancellation: true, noiseSuppression: true },\n      });\n      const recorder = new MediaRecorder(stream);\n      const ctx = new AudioContext();\n      const analyser = ctx.createAnalyser();\n      analyser.fftSize = 1024;\n      ctx.createMediaStreamSource(stream).connect(analyser);\n      const buffer = new Uint8Array(analyser.fftSize);\n      const s = {\n        stream,\n        recorder,\n        ctx,\n        chunks: [] as Blob[],\n        all: [] as number[],\n        started: performance.now(),\n        raf: 0,\n      };\n      session.current = s;\n      recorder.ondataavailable = (e) => e.data.size && s.chunks.push(e.data);\n      recorder.start(250);\n      setState(\"recording\");\n\n      // Sample loudness ~16 times a second: enough for bars that feel live, cheap enough to re-render.\n      let last = 0;\n      const tick = (now: number) => {\n        s.raf = requestAnimationFrame(tick);\n        if (now - last < 60) return;\n        last = now;\n        analyser.getByteTimeDomainData(buffer);\n        let sum = 0;\n        for (let i = 0; i < buffer.length; i++) {\n          const v = (buffer[i] - 128) / 128;\n          sum += v * v;\n        }\n        const level = Math.min(1, Math.sqrt(sum / buffer.length) * 4.5);\n        s.all.push(level);\n        setLevels((l) => (l.length >= 72 ? [...l.slice(1), level] : [...l, level]));\n        setElapsed((now - s.started) / 1000);\n      };\n      s.raf = requestAnimationFrame(tick);\n    } catch (error) {\n      teardown();\n      setState(\"idle\");\n      const name = error instanceof DOMException ? error.name : \"\";\n      onErrorRef.current?.(\n        name === \"NotAllowedError\" || name === \"SecurityError\"\n          ? \"Microphone access is blocked. Allow it in your browser’s site settings.\"\n          : name === \"NotFoundError\"\n            ? \"No microphone found. Connect one and try again.\"\n            : name === \"NotSupportedError\"\n              ? \"This browser can’t record audio.\"\n              : \"Couldn’t start recording. Try again.\",\n      );\n    }\n  }, [teardown]);\n\n  const cancel = useCallback(() => {\n    session.current?.recorder.state !== \"inactive\" && session.current?.recorder.stop();\n    teardown();\n    setState(\"idle\");\n  }, [teardown]);\n\n  /** Stops and resolves with the clip, or null if nothing usable was recorded. */\n  const stop = useCallback(\n    () =>\n      new Promise<VoiceRecording | null>((resolve) => {\n        const s = session.current;\n        if (!s) return resolve(null);\n        const duration = (performance.now() - s.started) / 1000;\n        s.recorder.onstop = () => {\n          const blob = new Blob(s.chunks, {\n            type: s.recorder.mimeType || \"audio/webm\",\n          });\n          const peaks = Array.from({ length: 48 }, (_, i) => {\n            const from = Math.floor((i / 48) * s.all.length);\n            const to = Math.max(from + 1, Math.floor(((i + 1) / 48) * s.all.length));\n            const slice = s.all.slice(from, to);\n            return slice.length ? Math.max(...slice) : 0;\n          });\n          resolve(blob.size ? { blob, url: URL.createObjectURL(blob), duration, peaks } : null);\n        };\n        s.recorder.stop();\n        teardown();\n        setState(\"idle\");\n      }),\n    [teardown],\n  );\n\n  return { state, levels, elapsed, start, stop, cancel };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * MessageComposer\n * -----------------------------------------------------------------------------------------------*/\n\nexport type MessageComposerProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"defaultValue\" | \"onChange\"> & {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (text: string) => void;\n  attachments?: ComposerAttachment[];\n  defaultAttachments?: ComposerAttachment[];\n  onAttachmentsChange?: (attachments: ComposerAttachment[]) => void;\n  /** Called with the trimmed text and the attachments. The composer clears itself straight away. */\n  onSend: (message: ComposerMessage) => void;\n  /** Turns on voice notes: the button shows a mic while the field is empty. */\n  onVoice?: (recording: VoiceRecording) => void;\n  /** Fires true while someone is typing and false 3s after they stop, send or leave. */\n  onTypingChange?: (typing: boolean) => void;\n  placeholder?: string;\n  /** Soft limit. A count appears in the last 10% and sending stops past it. */\n  maxLength?: number;\n  /** Lines before the field scrolls instead of growing. */\n  maxRows?: number;\n  /** File types the picker offers, as for an input's accept. */\n  accept?: string;\n  /** Bytes. Larger files are refused with a message that names them. */\n  maxFileSize?: number;\n  /** Keep the draft in localStorage under this key, so a reload doesn't lose it. */\n  draftKey?: string;\n  /** Emoji in the picker, in order. */\n  emojis?: { emoji: string; label: string }[];\n  /** Above the field, inside the box: a reply preview, an editing banner. */\n  header?: React.ReactNode;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  /** Where the emoji picker and tooltips portal. Defaults to document.body. */\n  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n  /** The field's accessible name. */\n  label?: string;\n};\n\ntype Notice = { id: number; text: string };\n\n/**\n * A chat composer that grows with its text, takes files by picker, paste or drop, inserts\n * emoji at the caret, and turns its mic into a send button the moment there's something to send.\n */\nexport function MessageComposer({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  attachments: attachmentsProp,\n  defaultAttachments = [],\n  onAttachmentsChange,\n  onSend,\n  onVoice,\n  onTypingChange,\n  placeholder = \"Message\",\n  maxLength,\n  maxRows = 8,\n  accept,\n  maxFileSize,\n  draftKey,\n  emojis = defaultEmojis,\n  header,\n  disabled = false,\n  autoFocus = false,\n  container,\n  label = \"Message\",\n  className,\n  ...rest\n}: MessageComposerProps) {\n  const reduce = !!useReducedMotion();\n  const uid = useId();\n  const noticeId = `${uid}-notice`;\n  const coarse = useSyncExternalStore(\n    subscribeCoarse,\n    () => window.matchMedia(coarseQuery).matches,\n    () => false,\n  );\n\n  const [text, setText] = useControllableState({\n    value: valueProp,\n    defaultValue,\n    onChange: onValueChange,\n  });\n  const [files, setFiles] = useControllableState({\n    value: attachmentsProp,\n    defaultValue: defaultAttachments,\n    onChange: onAttachmentsChange,\n  });\n  const [notice, setNotice] = useState<Notice | null>(null);\n  const [dragging, setDragging] = useState(false);\n  const [emojiOpen, setEmojiOpen] = useState(false);\n  const [atMax, setAtMax] = useState(false);\n\n  const areaRef = useRef<HTMLTextAreaElement>(null);\n  const replicaRef = useRef<HTMLDivElement>(null);\n  const fileRef = useRef<HTMLInputElement>(null);\n  const noticeTimer = useRef<number>(undefined);\n  const typingTimer = useRef<number>(undefined);\n  const typing = useRef(false);\n  const dragDepth = useRef(0);\n  const madeUrls = useRef(new Set<string>());\n  const pickedEmoji = useRef(false);\n\n  const say = useCallback((message: string) => {\n    window.clearTimeout(noticeTimer.current);\n    setNotice((n) => ({ id: (n?.id ?? 0) + 1, text: message }));\n    noticeTimer.current = window.setTimeout(() => setNotice(null), 6000);\n  }, []);\n\n  const recorder = useVoiceRecorder({ onError: say });\n  const recording = recorder.state !== \"idle\";\n\n  /* ----- draft ----- */\n  const draftStorageKey = draftKey ? `stealth:message-draft:${draftKey}` : null;\n  const restored = useRef(false);\n  useEffect(() => {\n    if (!draftStorageKey || restored.current) return;\n    restored.current = true;\n    if (valueProp !== undefined || text) return;\n    const saved = storage.read(draftStorageKey);\n    // A one-time sync from storage after hydration.\n    if (saved) setText(saved);\n  }, [draftStorageKey, valueProp, text, setText]);\n  useEffect(() => {\n    if (!draftStorageKey || !restored.current) return;\n    const t = window.setTimeout(() => storage.write(draftStorageKey, text), 300);\n    return () => window.clearTimeout(t);\n  }, [draftStorageKey, text]);\n\n  /* ----- typing signal ----- */\n  const onTypingRef = useRef(onTypingChange);\n  useEffect(() => {\n    onTypingRef.current = onTypingChange;\n  }, [onTypingChange]);\n  const stopTyping = useCallback(() => {\n    window.clearTimeout(typingTimer.current);\n    if (!typing.current) return;\n    typing.current = false;\n    onTypingRef.current?.(false);\n  }, []);\n  const markTyping = () => {\n    if (!typing.current) {\n      typing.current = true;\n      onTypingRef.current?.(true);\n    }\n    window.clearTimeout(typingTimer.current);\n    typingTimer.current = window.setTimeout(stopTyping, 3000);\n  };\n\n  useEffect(\n    () => () => {\n      window.clearTimeout(noticeTimer.current);\n      window.clearTimeout(typingTimer.current);\n    },\n    [],\n  );\n\n  /* ----- height: follows the replica; grows upward so the caret line never leaves view ----- */\n  const lineHeight = 20;\n  const maxHeight = maxRows * lineHeight + 12;\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const el = replicaRef.current;\n    if (!el) return;\n    let last = el.offsetHeight;\n    let running: AnimationPlaybackControls | undefined;\n    const ro = new ResizeObserver(() => {\n      const next = el.offsetHeight;\n      setAtMax(el.scrollHeight > el.clientHeight + 1);\n      if (next === last) return;\n      const from = last;\n      last = next;\n      running?.stop();\n      if (reduce) return height.set(next);\n      if (height.get() === \"auto\") height.set(from);\n      running = animate(height, next, { duration: 0.12, ease: ease.out });\n    });\n    ro.observe(el);\n    return () => {\n      ro.disconnect();\n      running?.stop();\n    };\n  }, [height, reduce]);\n\n  /* ----- attachments ----- */\n  const addFiles = (list: FileList | File[]) => {\n    const picked = Array.from(list);\n    if (!picked.length) return;\n    const tooBig = maxFileSize ? picked.filter((f) => f.size > maxFileSize) : [];\n    const ok = picked.filter((f) => !tooBig.includes(f));\n    if (tooBig.length) {\n      const limit = formatBytes(maxFileSize!);\n      say(tooBig.length === 1 ? `${tooBig[0].name} is over ${limit}. Choose a smaller file.` : `${tooBig.length} files are over ${limit}. Choose smaller files.`);\n    }\n    if (!ok.length) return;\n    const added = ok.map<ComposerAttachment>((file) => {\n      const preview = file.type.startsWith(\"image/\") ? URL.createObjectURL(file) : undefined;\n      if (preview) madeUrls.current.add(preview);\n      return {\n        id: nextId(),\n        name: file.name || \"Pasted image\",\n        size: file.size,\n        type: file.type,\n        file,\n        preview,\n      };\n    });\n    setFiles((prev) => [...prev, ...added]);\n    areaRef.current?.focus({ preventScroll: true });\n  };\n\n  const removeFile = (id: string) => {\n    const gone = files.find((f) => f.id === id);\n    if (gone?.preview && madeUrls.current.has(gone.preview)) {\n      URL.revokeObjectURL(gone.preview);\n      madeUrls.current.delete(gone.preview);\n    }\n    setFiles((prev) => prev.filter((f) => f.id !== id));\n    areaRef.current?.focus({ preventScroll: true });\n  };\n\n  /* ----- send ----- */\n  const trimmed = text.trim();\n  const over = maxLength != null && text.length > maxLength;\n  const near = maxLength != null && text.length >= maxLength * 0.9;\n  const hasContent = trimmed.length > 0 || files.length > 0;\n  const canSend = hasContent && !over && !disabled;\n  const showMic = !!onVoice && !hasContent;\n\n  const send = () => {\n    if (!canSend) return;\n    onSend({ text: trimmed, attachments: files });\n    // Sent previews now belong to the message; stop tracking them so they aren't revoked.\n    for (const f of files) if (f.preview) madeUrls.current.delete(f.preview);\n    setText(\"\");\n    setFiles([]);\n    if (draftStorageKey) storage.write(draftStorageKey, \"\");\n    stopTyping();\n    setNotice(null);\n    areaRef.current?.focus({ preventScroll: true });\n  };\n\n  const startRecording = () => {\n    setNotice(null);\n    void recorder.start();\n  };\n\n  const finishRecording = async () => {\n    const clip = await recorder.stop();\n    if (!clip || clip.duration < 1) {\n      if (clip) URL.revokeObjectURL(clip.url);\n      say(\"Too short to send. Record for at least a second.\");\n    } else onVoice?.(clip);\n  };\n\n  const cancelRecording = () => recorder.cancel();\n\n  // When a recording ends either way, the caret goes back to the text. It waits for the\n  // row to stop being inert, because an inert field can't take focus.\n  const wasRecording = useRef(false);\n  useEffect(() => {\n    if (wasRecording.current && !recording) areaRef.current?.focus({ preventScroll: true });\n    wasRecording.current = recording;\n  }, [recording]);\n\n  const counter = maxLength != null && near && !recording;\n  const noticeText = notice?.text ?? (counter ? (over ? `${text.length - maxLength!} over the ${maxLength} character limit` : `${maxLength! - text.length} characters left`) : \"\");\n\n  const iconButton = cn(\n    \"relative grid size-8 shrink-0 place-items-center rounded-full text-fg-3 outline-none\",\n    \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n    \"disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-hover data-popup-open:text-fg\",\n    // 32px circles; on touch the target grows to 44px without changing the drawing.\n    \"before:absolute before:-inset-1.5 before:rounded-full before:content-[''] pointer-fine:before:hidden\",\n  );\n\n  return (\n    <form\n      noValidate\n      onSubmit={(e) => {\n        e.preventDefault();\n        send();\n      }}\n      data-disabled={disabled || undefined}\n      data-state={recording ? \"recording\" : hasContent ? \"ready\" : \"empty\"}\n      className={cn(\"flex w-full min-w-0 flex-col gap-1.5\", className)}\n      {...rest}\n    >\n      <Tooltip.Provider delay={500} closeDelay={0}>\n        <div\n          onDragEnter={(e) => {\n            if (disabled || recording || !e.dataTransfer.types.includes(\"Files\")) return;\n            e.preventDefault();\n            dragDepth.current++;\n            setDragging(true);\n          }}\n          onDragOver={(e) => {\n            if (dragging) e.preventDefault();\n          }}\n          onDragLeave={() => {\n            dragDepth.current = Math.max(0, dragDepth.current - 1);\n            if (!dragDepth.current) setDragging(false);\n          }}\n          onDrop={(e) => {\n            if (!dragging) return;\n            e.preventDefault();\n            dragDepth.current = 0;\n            setDragging(false);\n            addFiles(e.dataTransfer.files);\n          }}\n          onMouseDown={(e) => {\n            // A press on the box's padding puts the caret in the text rather than doing nothing.\n            if (e.target === e.currentTarget && !recording) {\n              e.preventDefault();\n              areaRef.current?.focus();\n            }\n          }}\n          className={cn(\n            \"relative flex min-w-0 flex-col rounded-[20px] border bg-raised shadow-[var(--shadow)]\",\n            \"transition-[border-color,box-shadow,opacity] duration-150 ease-out\",\n            \"has-[textarea:focus-visible]:ring-3\",\n            over\n              ? \"border-danger/50 has-[textarea:focus-visible]:border-danger/60 has-[textarea:focus-visible]:ring-danger/12\"\n              : cn(dragging ? \"border-fg-3\" : \"border-line-2 hover:border-fg-4\", \"has-[textarea:focus-visible]:border-fg-4 has-[textarea:focus-visible]:ring-fg/8\"),\n            disabled && \"pointer-events-none opacity-50\",\n          )}\n        >\n          {header}\n\n          {/* Attachments: a strip that opens above the text and closes when the last one goes. */}\n          <AnimatePresence initial={false}>\n            {files.length > 0 && !recording && (\n              <motion.div\n                key=\"strip\"\n                initial={{ height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={{\n                  height: 0,\n                  opacity: 0,\n                  transition: { duration: reduce ? 0 : 0.16, ease: ease.inOut },\n                }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        height: { duration: 0.22, ease: ease.out },\n                        opacity: { duration: 0.16 },\n                      }\n                }\n                className=\"overflow-hidden\"\n              >\n                <ul aria-label=\"Attachments\" className=\"flex gap-2 overflow-x-auto overscroll-x-contain px-2 pb-1 pt-2.5 [scrollbar-width:none]\">\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    {files.map((f) => (\n                      <Chip key={f.id} file={f} reduce={reduce} onRemove={() => removeFile(f.id)} />\n                    ))}\n                  </AnimatePresence>\n                </ul>\n              </motion.div>\n            )}\n          </AnimatePresence>\n\n          <div className=\"relative flex min-w-0 items-end gap-0.5 p-1\">\n            {/* The text row stays mounted under the recorder, so the draft, the caret and the\n                measured height are all still there when recording ends. */}\n            <AnimatePresence initial={false}>\n              {recording && (\n                <RecordingRow\n                  key=\"rec\"\n                  reduce={reduce}\n                  starting={recorder.state === \"starting\"}\n                  levels={recorder.levels}\n                  elapsed={recorder.elapsed}\n                  onCancel={cancelRecording}\n                  onSend={finishRecording}\n                  iconButton={iconButton}\n                  container={container}\n                />\n              )}\n            </AnimatePresence>\n            <motion.div\n              inert={recording || undefined}\n              className=\"flex min-w-0 flex-1 items-end gap-0.5\"\n              initial={false}\n              animate={recording ? { opacity: 0, filter: reduce ? \"blur(0px)\" : \"blur(2px)\" } : { opacity: 1, filter: \"blur(0px)\" }}\n              transition={{ duration: recording ? 0.1 : 0.18, ease: ease.out }}\n            >\n              <Tip label=\"Attach files\" container={container}>\n                <button type=\"button\" aria-label=\"Attach files\" disabled={disabled} onClick={() => fileRef.current?.click()} className={iconButton}>\n                  <Paperclip className=\"transition-transform duration-200 ease-out-expo group-hover:rotate-0\" />\n                </button>\n              </Tip>\n              <input\n                ref={fileRef}\n                type=\"file\"\n                multiple\n                accept={accept}\n                tabIndex={-1}\n                aria-hidden\n                className=\"hidden\"\n                onChange={(e) => {\n                  if (e.target.files) addFiles(e.target.files);\n                  e.target.value = \"\";\n                }}\n              />\n\n              {/* The replica sets the height; the textarea fills it. Both share every text metric. */}\n              <motion.div style={{ height }} className=\"relative min-w-0 flex-1 overflow-hidden\">\n                <div\n                  ref={replicaRef}\n                  aria-hidden\n                  style={{ maxHeight }}\n                  className=\"invisible overflow-hidden whitespace-pre-wrap px-2 py-1.5 text-base leading-5 [overflow-wrap:anywhere] sm:text-[13px]\"\n                >\n                  {text + \" \"}\n                </div>\n                <textarea\n                  ref={areaRef}\n                  value={text}\n                  rows={1}\n                  disabled={disabled}\n                  autoFocus={autoFocus}\n                  placeholder={placeholder}\n                  aria-label={label}\n                  aria-invalid={over || undefined}\n                  aria-describedby={noticeText ? noticeId : undefined}\n                  aria-keyshortcuts={coarse ? undefined : \"Enter\"}\n                  enterKeyHint={coarse ? \"enter\" : \"send\"}\n                  autoComplete=\"off\"\n                  onChange={(e) => {\n                    setText(e.target.value);\n                    if (e.target.value) markTyping();\n                    else stopTyping();\n                    if (notice) setNotice(null);\n                  }}\n                  onBlur={stopTyping}\n                  onPaste={(e) => {\n                    if (e.clipboardData.files.length) {\n                      e.preventDefault();\n                      addFiles(e.clipboardData.files);\n                    }\n                  }}\n                  onKeyDown={(e) => {\n                    if (e.nativeEvent.isComposing || e.keyCode === 229) return;\n                    if (e.key !== \"Enter\") return;\n                    // Enter sends on a keyboard; on a phone it's a new line and the button sends.\n                    const mod = e.metaKey || e.ctrlKey;\n                    if (e.shiftKey || e.altKey || (coarse && !mod)) return;\n                    e.preventDefault();\n                    send();\n                  }}\n                  className={cn(\n                    \"absolute inset-0 block size-full resize-none bg-transparent px-2 py-1.5 text-base leading-5 text-fg outline-none [overflow-wrap:anywhere] sm:text-[13px]\",\n                    \"placeholder:text-fg-4 disabled:cursor-not-allowed\",\n                    // Past the last row it scrolls, and the top edge fades so a cut line reads as \"more above\".\n                    atMax ? \"overflow-y-auto overscroll-contain [mask-image:linear-gradient(to_bottom,transparent,black_14px)]\" : \"overflow-y-hidden\",\n                  )}\n                />\n              </motion.div>\n\n              <Popover.Root open={emojiOpen} onOpenChange={setEmojiOpen}>\n                <Tip label=\"Emoji\" container={container} disabled={emojiOpen}>\n                  <Popover.Trigger aria-label=\"Add emoji\" disabled={disabled} className={cn(iconButton, \"group/emoji\")}>\n                    <SmileGlyph />\n                  </Popover.Trigger>\n                </Tip>\n                <Popover.Portal container={container}>\n                  <Popover.Positioner side=\"top\" align=\"end\" sideOffset={10} collisionPadding={8} className=\"z-(--z-popover)\">\n                    <Popover.Popup\n                      finalFocus={() => {\n                        // After a pick the caret goes back to the text; Escape returns to the button.\n                        if (pickedEmoji.current) {\n                          pickedEmoji.current = false;\n                          return areaRef.current;\n                        }\n                        return true;\n                      }}\n                      className={cn(\n                        \"rounded-xl border border-line-2 bg-raised p-1.5 text-fg shadow-pop outline-none\",\n                        \"origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-180 ease-out-expo\",\n                        \"data-starting-style:scale-[0.94] data-starting-style:opacity-0 data-starting-style:blur-[2px] data-[side=top]:data-starting-style:translate-y-1\",\n                        \"data-ending-style:scale-[0.97] data-ending-style:opacity-0 data-ending-style:duration-120 data-ending-style:ease-out-quart\",\n                        \"data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:blur-none\",\n                      )}\n                    >\n                      <Popover.Title className=\"sr-only\">Emoji</Popover.Title>\n                      <EmojiGrid\n                        emojis={emojis}\n                        onPick={(emoji) => {\n                          pickedEmoji.current = true;\n                          setEmojiOpen(false);\n                          const el = areaRef.current;\n                          if (el) insertAtCaret(el, emoji);\n                        }}\n                      />\n                    </Popover.Popup>\n                  </Popover.Positioner>\n                </Popover.Portal>\n              </Popover.Root>\n\n              <SendButton\n                mic={showMic}\n                ready={canSend}\n                disabled={disabled || (!showMic && !canSend)}\n                reduce={reduce}\n                shortcut={coarse ? undefined : \"Enter\"}\n                container={container}\n                onMic={startRecording}\n              />\n            </motion.div>\n          </div>\n\n          {/* Drop target: the whole box, said plainly. */}\n          <AnimatePresence>\n            {dragging && (\n              <motion.div\n                key=\"drop\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: 0.14 }}\n                className=\"pointer-events-none absolute inset-0 grid place-items-center rounded-[inherit] bg-raised/95\"\n              >\n                <span className=\"flex items-center gap-2 text-[12.5px] font-medium text-fg\">\n                  <Paperclip className=\"text-fg-3\" />\n                  Drop to attach\n                </span>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </Tooltip.Provider>\n\n      {/* One line under the box for anything that needs saying: a refused file, the mic, the limit.\n          It opens only when there's something to say, so the composer sits flush the rest of the time. */}\n      <div\n        className={cn(\n          \"-mt-1.5 grid transition-[grid-template-rows,margin] duration-200 ease-out-expo motion-reduce:transition-none\",\n          noticeText ? \"mt-0 grid-rows-[1fr]\" : \"grid-rows-[0fr]\",\n        )}\n      >\n        <div className=\"relative min-h-0 overflow-hidden\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {noticeText && (\n              <motion.p\n                key={notice ? `n${notice.id}` : \"count\"}\n                id={noticeId}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: 0.18, ease: ease.out }}\n                className={cn(\"truncate px-3 text-[11.5px] leading-4 tabular\", notice || over ? \"text-danger\" : \"text-fg-3\", !notice && \"text-right\")}\n              >\n                {noticeText}\n              </motion.p>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {notice?.text ?? (over ? \"Over the character limit\" : \"\")}\n      </span>\n    </form>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The send button: a mic while there's nothing to send, a filled disc the moment there is\n * -----------------------------------------------------------------------------------------------*/\n\nfunction SendButton({\n  mic,\n  ready,\n  disabled,\n  reduce,\n  shortcut,\n  container,\n  onMic,\n}: {\n  mic: boolean;\n  ready: boolean;\n  disabled: boolean;\n  reduce: boolean;\n  shortcut?: string;\n  container?: MessageComposerProps[\"container\"];\n  onMic: () => void;\n}) {\n  const filled = ready && !mic;\n  const tip = mic ? \"Record voice message\" : \"Send\";\n  return (\n    <Tip label={tip} keys={!mic ? shortcut && \"↵\" : undefined} container={container}>\n      <button\n        type={mic ? \"button\" : \"submit\"}\n        aria-label={mic ? \"Record voice message\" : \"Send message\"}\n        aria-keyshortcuts={!mic ? shortcut : undefined}\n        disabled={disabled}\n        data-state={mic ? \"mic\" : filled ? \"ready\" : \"empty\"}\n        onClick={mic ? onMic : undefined}\n        className={cn(\n          \"group/send relative grid size-8 shrink-0 place-items-center rounded-full outline-none\",\n          \"transition-[color,scale] duration-150 ease-out active:scale-[0.9] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          \"before:absolute before:-inset-1.5 before:rounded-full before:content-[''] pointer-fine:before:hidden\",\n          filled ? \"text-frame\" : mic ? \"text-fg-3 hover:bg-hover hover:text-fg\" : \"text-fg-4\",\n          \"disabled:pointer-events-none\",\n        )}\n      >\n        {/* The disc fills from the centre when there's something to send, and drains when there isn't. */}\n        <motion.span\n          aria-hidden\n          className=\"absolute inset-0 rounded-full bg-fg transition-[background-color] duration-150 group-hover/send:bg-fg/90\"\n          initial={false}\n          animate={filled ? { scale: 1, opacity: 1 } : mic ? { scale: 0.4, opacity: 0 } : { scale: 1, opacity: 0.1 }}\n          transition={reduce ? { duration: 0.12 } : filled ? spring.pop : { duration: 0.16, ease: ease.out }}\n        />\n        <span className=\"relative grid size-4 place-items-center\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={mic ? \"mic\" : \"send\"}\n              className=\"absolute inset-0 grid place-items-center\"\n              initial={\n                reduce\n                  ? { opacity: 0 }\n                  : {\n                      opacity: 0,\n                      scale: 0.5,\n                      rotate: mic ? 0 : -45,\n                      filter: \"blur(3px)\",\n                    }\n              }\n              animate={{ opacity: 1, scale: 1, rotate: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : {\n                      opacity: 0,\n                      scale: 0.5,\n                      filter: \"blur(3px)\",\n                      transition: { duration: 0.12 },\n                    }\n              }\n              transition={reduce ? { duration: 0.12 } : spring.pop}\n            >\n              {mic ? <Mic /> : <ArrowUp strokeWidth={1.7} />}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </button>\n    </Tip>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Recording row: cancel, a live clock, bars that scroll in from the right, send\n * -----------------------------------------------------------------------------------------------*/\n\nfunction RecordingRow({\n  reduce,\n  starting,\n  levels,\n  elapsed,\n  onCancel,\n  onSend,\n  iconButton,\n  container,\n}: {\n  reduce: boolean;\n  starting: boolean;\n  levels: number[];\n  elapsed: number;\n  onCancel: () => void;\n  onSend: () => void;\n  iconButton: string;\n  container?: MessageComposerProps[\"container\"];\n}) {\n  const sendRef = useRef<HTMLButtonElement>(null);\n  // Focus lands on send once the mic is live, so Enter sends and Escape discards.\n  useEffect(() => {\n    if (!starting) sendRef.current?.focus({ preventScroll: true });\n  }, [starting]);\n\n  return (\n    <motion.div\n      className=\"absolute inset-1 z-1 flex min-w-0 items-center gap-0.5\"\n      initial={reduce ? { opacity: 0 } : { opacity: 0, filter: \"blur(2px)\" }}\n      animate={{ opacity: 1, filter: \"blur(0px)\" }}\n      exit={{ opacity: 0, transition: { duration: 0.1 } }}\n      transition={{ duration: 0.18, ease: ease.out }}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\") {\n          e.preventDefault();\n          onCancel();\n        }\n      }}\n    >\n      <Tip label=\"Discard recording\" keys=\"Esc\" container={container}>\n        <button type=\"button\" aria-label=\"Discard recording\" onClick={onCancel} className={cn(iconButton, \"hover:text-danger\")}>\n          <Trash />\n        </button>\n      </Tip>\n\n      <div className=\"flex h-8 min-w-0 flex-1 items-center gap-2.5 px-1.5\">\n        <span className=\"flex shrink-0 items-center gap-1.5 text-[12.5px] text-fg tabular\">\n          <span aria-hidden className=\"relative grid size-2 place-items-center\">\n            <span className=\"size-2 rounded-full bg-danger\" />\n            {!starting && <span className=\"absolute inset-0 rounded-full bg-danger motion-safe:animate-ping-soft\" />}\n          </span>\n          <span className=\"w-9\">{starting ? \"0:00\" : clockFormat(elapsed)}</span>\n        </span>\n        {starting ? (\n          <span className=\"truncate text-[12.5px] text-fg-3\">Waiting for the microphone…</span>\n        ) : (\n          <span aria-hidden className=\"flex h-6 min-w-0 flex-1 items-center justify-end gap-[2px] overflow-hidden\">\n            {levels.map((l, i) => (\n              <span key={levels.length - i} className=\"w-[2px] shrink-0 rounded-full bg-fg/70\" style={{ height: `${Math.max(2, Math.round(2 + l * 20))}px` }} />\n            ))}\n          </span>\n        )}\n      </div>\n\n      <Tip label=\"Send voice message\" keys=\"↵\" container={container}>\n        <button\n          ref={sendRef}\n          type=\"button\"\n          aria-label=\"Send voice message\"\n          disabled={starting}\n          onClick={onSend}\n          className={cn(\n            \"relative grid size-8 shrink-0 place-items-center rounded-full bg-fg text-frame outline-none\",\n            \"transition-[background-color,scale,opacity] duration-150 ease-out hover:bg-fg/90 active:scale-[0.9] active:duration-75 disabled:opacity-40\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n            \"before:absolute before:-inset-1.5 before:rounded-full before:content-[''] pointer-fine:before:hidden\",\n          )}\n        >\n          <ArrowUp strokeWidth={1.7} />\n        </button>\n      </Tip>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {starting ? \"Waiting for the microphone\" : \"Recording. Press Enter to send or Escape to discard.\"}\n      </span>\n    </motion.div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Attachment chip\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Chip({ file, reduce, onRemove }: { file: ComposerAttachment; reduce: boolean; onRemove: () => void }) {\n  const image = !!file.preview && file.type.startsWith(\"image/\");\n  const [base, ext] = splitName(file.name);\n  const uploading = file.progress != null && file.progress < 1 && !file.error;\n  const [broken, setBroken] = useState(false);\n\n  return (\n    <motion.li\n      layout={reduce ? false : \"position\"}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85, filter: \"blur(2px)\" }}\n      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n      exit={\n        reduce\n          ? { opacity: 0, transition: { duration: 0.1 } }\n          : {\n              opacity: 0,\n              scale: 0.85,\n              filter: \"blur(2px)\",\n              transition: { duration: 0.12, ease: ease.in },\n            }\n      }\n      transition={reduce ? { duration: 0.12 } : { ...spring.pop, layout: spring.snappy }}\n      data-error={file.error ? \"\" : undefined}\n      className=\"group/chip relative shrink-0\"\n    >\n      <div\n        className={cn(\n          \"relative flex h-12 items-center overflow-hidden rounded-xl border bg-frame\",\n          file.error ? \"border-danger/50\" : \"border-line-2\",\n          image ? \"w-12\" : \"max-w-[220px] gap-2.5 pl-1.5 pr-3\",\n        )}\n      >\n        {image && !broken ? (\n          // eslint-disable-next-line @next/next/no-img-element -- local object URL preview\n          <img src={file.preview} alt=\"\" onError={() => setBroken(true)} className=\"size-full object-cover\" />\n        ) : (\n          <>\n            <span className=\"grid size-9 shrink-0 place-items-center rounded-lg bg-hover font-mono text-[9.5px] uppercase tracking-[0.04em] text-fg-2\">\n              {(ext || (image ? \"img\" : \"file\")).slice(0, 4)}\n            </span>\n            {!image && (\n              <span className=\"flex min-w-0 flex-col\">\n                <span className=\"flex min-w-0 text-[12.5px] leading-4 text-fg\">\n                  <span className=\"truncate\">{base}</span>\n                  {ext && <span className=\"shrink-0\">.{ext}</span>}\n                </span>\n                <span className={cn(\"text-[11px] leading-4 tabular\", file.error ? \"text-danger\" : \"text-fg-3\")}>\n                  {file.error ?? (uploading ? `${Math.round((file.progress ?? 0) * 100)}% of ${formatBytes(file.size)}` : formatBytes(file.size))}\n                </span>\n              </span>\n            )}\n          </>\n        )}\n        {uploading && (\n          <span aria-hidden className=\"absolute inset-x-0 bottom-0 h-[2px] bg-fg/10\">\n            <span className=\"block h-full origin-left bg-fg transition-transform duration-200 ease-out\" style={{ transform: `scaleX(${file.progress})` }} />\n          </span>\n        )}\n      </div>\n      <button\n        type=\"button\"\n        aria-label={`Remove ${file.name}`}\n        onClick={onRemove}\n        className={cn(\n          \"absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] outline-none\",\n          \"transition-[opacity,scale,color,background-color] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.88] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          // Hidden until the chip is hovered or focused on a mouse; always there on touch.\n          \"pointer-fine:opacity-0 pointer-fine:group-hover/chip:opacity-100 pointer-fine:focus-visible:opacity-100\",\n          \"before:absolute before:-inset-3 before:rounded-full before:content-[''] pointer-fine:before:-inset-1\",\n        )}\n      >\n        <X size={12} />\n      </button>\n    </motion.li>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Emoji grid: arrow keys, one tab stop\n * -----------------------------------------------------------------------------------------------*/\n\nconst COLUMNS = 8;\n\nfunction EmojiGrid({ emojis, onPick }: { emojis: { emoji: string; label: string }[]; onPick: (emoji: string) => void }) {\n  const [active, setActive] = useState(0);\n  const grid = useRef<HTMLDivElement>(null);\n  const move = (e: React.KeyboardEvent) => {\n    const last = emojis.length - 1;\n    const next =\n      e.key === \"ArrowRight\"\n        ? Math.min(last, active + 1)\n        : e.key === \"ArrowLeft\"\n          ? Math.max(0, active - 1)\n          : e.key === \"ArrowDown\"\n            ? Math.min(last, active + COLUMNS)\n            : e.key === \"ArrowUp\"\n              ? Math.max(0, active - COLUMNS)\n              : e.key === \"Home\"\n                ? 0\n                : e.key === \"End\"\n                  ? last\n                  : -1;\n    if (next < 0) return;\n    e.preventDefault();\n    setActive(next);\n    grid.current?.querySelectorAll<HTMLElement>(\"[role=gridcell]\")[next]?.focus();\n  };\n  const rows = Array.from({ length: Math.ceil(emojis.length / COLUMNS) }, (_, i) => emojis.slice(i * COLUMNS, i * COLUMNS + COLUMNS));\n  return (\n    <div ref={grid} role=\"grid\" aria-label=\"Emoji\" onKeyDown={move} className=\"flex flex-col gap-0.5\">\n      {rows.map((row, ri) => (\n        <div key={ri} role=\"row\" className=\"flex gap-0.5\">\n          {row.map((c, ci) => {\n            const i = ri * COLUMNS + ci;\n            return (\n              <button\n                key={c.emoji}\n                type=\"button\"\n                role=\"gridcell\"\n                aria-label={c.label}\n                tabIndex={i === active ? 0 : -1}\n                onFocus={() => setActive(i)}\n                onClick={() => onPick(c.emoji)}\n                className={cn(\n                  \"group/cell grid size-8 place-items-center rounded-lg outline-none\",\n                  \"transition-[background-color,scale] duration-150 ease-out hover:bg-hover active:scale-[0.9] active:duration-75\",\n                  \"focus-visible:bg-hover focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4\",\n                )}\n              >\n                <span\n                  aria-hidden\n                  className=\"text-[18px] leading-none transition-transform duration-200 ease-out-expo group-hover/cell:scale-[1.18] group-focus-visible/cell:scale-[1.18] motion-reduce:transition-none motion-reduce:group-hover/cell:scale-100\"\n                >\n                  {c.emoji}\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      ))}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Tooltip and the one glyph the shared set doesn't have\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Tip({\n  label,\n  keys,\n  disabled,\n  container,\n  children,\n}: {\n  label: string;\n  keys?: string;\n  disabled?: boolean;\n  container?: MessageComposerProps[\"container\"];\n  children: React.ReactElement;\n}) {\n  return (\n    <Tooltip.Root disabled={disabled}>\n      <Tooltip.Trigger render={children} />\n      <Tooltip.Portal container={container}>\n        <Tooltip.Positioner side=\"top\" sideOffset={8} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"flex items-center gap-2 rounded-md border border-line-2 bg-raised py-1 pl-2 text-[12px] leading-4 text-fg shadow-pop outline-none\",\n              keys ? \"pr-1\" : \"pr-2\",\n              \"origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100\",\n              \"data-instant:transition-none motion-reduce:data-starting-style:scale-100\",\n            )}\n          >\n            {label}\n            {keys && <kbd className=\"rounded-[4px] border border-line-2 bg-frame px-1 font-mono text-[10.5px] leading-[14px] text-fg-3\">{keys}</kbd>}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\nfunction SmileGlyph() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n      {/* The smile widens a touch on hover: the button answers before it's pressed. */}\n      <path\n        d=\"M5.9 9.4a2.6 2.6 0 0 0 4.2 0\"\n        className=\"origin-[8px_9.6px] transition-transform duration-200 ease-out-expo group-hover/emoji:scale-x-[1.18] group-data-popup-open/emoji:scale-x-[1.18]\"\n      />\n      <circle cx=\"6.1\" cy=\"6.6\" r=\".7\" fill=\"currentColor\" stroke=\"none\" />\n      <circle cx=\"9.9\" cy=\"6.6\" r=\".7\" fill=\"currentColor\" stroke=\"none\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/message-composer"
}
