{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comment-composer",
  "type": "registry:ui",
  "title": "Comment Composer",
  "description": "Grows on focus, @mentions teammates, sends on ⌘↵ and keeps the draft.",
  "categories": [
    "collaboration"
  ],
  "dependencies": [
    "@base-ui/react",
    "@floating-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/comment-composer.tsx",
      "content": "\"use client\";\nimport { autoUpdate, flip, FloatingPortal, offset, shift, size, useFloating } from \"@floating-ui/react\";\nimport { Toolbar } from \"@base-ui/react/toolbar\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { AtSign, Check, Code, Loader } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Types and text helpers\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ComposerUser = {\n  id: string;\n  name: string;\n  /** A second line in the suggestions: a role, a team, an email. */\n  detail?: string;\n  avatarUrl?: string;\n};\n\nexport type ComposerMention = { id: string; name: string; start: number; end: number };\n\nexport type ComposerComment = { body: string; mentions: ComposerMention[] };\n\n/**\n * Finds \"@Name\" for every known user in the text. Mentions are derived from the text rather\n * than tracked alongside it, so a controlled value, a paste or an undo can never desync them.\n */\nexport function findMentions(text: string, users: ComposerUser[]): ComposerMention[] {\n  if (!users.length || !text.includes(\"@\")) return [];\n  const byLength = [...users].sort((a, b) => b.name.length - a.name.length);\n  const out: ComposerMention[] = [];\n  for (let i = text.indexOf(\"@\"); i !== -1; i = text.indexOf(\"@\", i + 1)) {\n    if (i > 0 && !/[\\s([{\"'“]/.test(text[i - 1])) continue;\n    const user = byLength.find((u) => text.startsWith(u.name, i + 1) && !/[\\p{L}\\p{N}_]/u.test(text[i + 1 + u.name.length] ?? \"\"));\n    if (!user) continue;\n    out.push({ id: user.id, name: user.name, start: i, end: i + 1 + user.name.length });\n    i += user.name.length;\n  }\n  return out;\n}\n\n/** An \"@query\" that ends at the caret and isn't already a finished mention. */\nfunction findTrigger(text: string, caret: number, mentions: ComposerMention[]) {\n  const before = text.slice(0, caret);\n  const at = before.lastIndexOf(\"@\");\n  if (at < 0) return null;\n  if (at > 0 && !/[\\s([{\"'“]/.test(before[at - 1])) return null;\n  const query = before.slice(at + 1);\n  // One space allowed, so \"@mara ok\" still narrows to Mara Okafor.\n  if (!/^[^\\s@]*( [^\\s@]*)?$/.test(query)) return null;\n  if (mentions.some((m) => at >= m.start && at < m.end)) return null;\n  return { at, query };\n}\n\nconst initials = (name: string) =>\n  name\n    .trim()\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase() ?? \"\")\n    .join(\"\");\n\n/**\n * Edits through the browser's own insertText so Cmd+Z undoes a mention or a bold like any\n * typing. Falls back to setRangeText plus a synthetic input event where execCommand is gone.\n */\nfunction replaceRange(el: HTMLTextAreaElement, text: string, start: number, end: number) {\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\nconst noop = () => () => {};\nconst isApple = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);\nconst listFormat = typeof Intl !== \"undefined\" && \"ListFormat\" in Intl ? new Intl.ListFormat(undefined, { type: \"conjunction\" }) : null;\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 persisted. */\n    }\n  },\n};\n\n/* -------------------------------------------------------------------------------------------------\n * Icons drawn on the library grid (16px, 1.4 stroke) that the shared set doesn't have\n * -----------------------------------------------------------------------------------------------*/\n\nconst glyph = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\nconst BoldIcon = () => (\n  <svg {...glyph} strokeWidth={1.7}>\n    <path d=\"M5 3.25h3.6a2.3 2.3 0 0 1 0 4.6H5zM5 7.85h4.1a2.45 2.45 0 0 1 0 4.9H5z\" />\n  </svg>\n);\nconst ItalicIcon = () => (\n  <svg {...glyph}>\n    <path d=\"M10.75 3.25h-4M9.25 12.75h-4M8.9 3.25 7.1 12.75\" />\n  </svg>\n);\n\n/* -------------------------------------------------------------------------------------------------\n * CommentComposer\n * -----------------------------------------------------------------------------------------------*/\n\ntype Phase = \"idle\" | \"sending\" | \"sent\";\n\nexport type CommentComposerProps = Omit<React.ComponentProps<\"div\">, \"onSubmit\" | \"defaultValue\" | \"onChange\" | \"children\"> & {\n  /** Post the comment. Return a promise: while it's pending the field locks; a rejection keeps the text and shows an error. */\n  onSubmit: (comment: ComposerComment) => void | Promise<unknown>;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (text: string) => void;\n  /** People who can be @mentioned. Leave empty to turn mentions off. */\n  users?: ComposerUser[];\n  /** Shows the writer's avatar beside the field. */\n  author?: { name: string; avatarUrl?: string };\n  placeholder?: string;\n  /** The send button's label. */\n  submitLabel?: string;\n  /** Persist the draft in localStorage under this key, so a reload or a closed tab doesn't lose it. */\n  draftKey?: string;\n  /** Characters allowed. A counter appears at 80%, and sending is blocked past it. */\n  maxLength?: number;\n  disabled?: boolean;\n  /** Start expanded, e.g. when opened from a \"Reply\" button. */\n  defaultExpanded?: boolean;\n  autoFocus?: boolean;\n  /** Where the mention list portals to. Defaults to document.body. */\n  portalContainer?: HTMLElement | null;\n};\n\nexport function CommentComposer({\n  onSubmit,\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  users = [],\n  author,\n  placeholder = \"Add a comment…\",\n  submitLabel = \"Comment\",\n  draftKey,\n  maxLength,\n  disabled = false,\n  defaultExpanded = false,\n  autoFocus = false,\n  portalContainer,\n  className,\n  ...rest\n}: CommentComposerProps) {\n  const reduce = !!useReducedMotion();\n  const apple = useSyncExternalStore(noop, isApple, () => true);\n  const uid = useId();\n  const listId = `${uid}-mentions`;\n  const hintId = `${uid}-hint`;\n  const errorId = `${uid}-error`;\n\n  const [text, setText] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [open, setOpenState] = useState(defaultExpanded);\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [error, setError] = useState<string | null>(null);\n  const [trigger, setTrigger] = useState<{ at: number; query: string } | null>(null);\n  const [dismissedAt, setDismissedAt] = useState<number | null>(null);\n  const [active, setActive] = useState(0);\n  const [flight, setFlight] = useState<{ id: number; text: string } | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const areaRef = useRef<HTMLTextAreaElement>(null);\n  const mirrorRef = useRef<HTMLDivElement>(null);\n  const growRef = useRef(false);\n  const sentTimer = useRef<number>(undefined);\n\n  const mentions = useMemo(() => findMentions(text, users), [text, users]);\n  const length = text.length;\n  const over = maxLength != null && length > maxLength;\n  const nearLimit = maxLength != null && length >= maxLength * 0.8;\n  const empty = text.trim().length === 0;\n  const busy = phase === \"sending\";\n  const expanded = open || busy || phase === \"sent\";\n\n  const setOpen = (next: boolean) => {\n    if (next === open) return;\n    growRef.current = true;\n    setOpenState(next);\n  };\n\n  /* ----- draft ----- */\n  const draftStorageKey = draftKey ? `stealth:comment-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    // Restoring a saved draft is 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  useEffect(() => () => window.clearTimeout(sentTimer.current), []);\n\n  /* ----- height: animated when it opens or closes, instant while typing ----- */\n  const height = useMotionValue<number | string>(\"auto\");\n  useEffect(() => {\n    const el = mirrorRef.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      if (next === last) return;\n      const from = last;\n      last = next;\n      running?.stop();\n      if (!growRef.current || reduce) return height.set(next);\n      growRef.current = false;\n      if (height.get() === \"auto\") height.set(from);\n      running = animate(height, next, { duration: 0.24, ease: ease.inOut });\n    });\n    ro.observe(el);\n    return () => {\n      ro.disconnect();\n      running?.stop();\n    };\n  }, [height, reduce]);\n\n  /* ----- mention suggestions ----- */\n  const matches = useMemo(() => {\n    if (!trigger || !users.length) return [];\n    const q = trigger.query.toLocaleLowerCase();\n    return users\n      .map((u) => {\n        const name = u.name.toLocaleLowerCase();\n        const score = !q ? 1 : name.startsWith(q) ? 4 : name.split(/\\s+/).some((w) => w.startsWith(q)) ? 3 : name.includes(q) ? 2 : (u.detail ?? \"\").toLocaleLowerCase().includes(q) ? 1 : 0;\n        return { u, score };\n      })\n      .filter((x) => x.score > 0)\n      .sort((a, b) => b.score - a.score)\n      .slice(0, 6)\n      .map((x) => x.u);\n  }, [trigger, users]);\n  const listOpen = Boolean(trigger) && dismissedAt !== trigger?.at && !busy && (matches.length > 0 || (trigger?.query.length ?? 0) > 0);\n  const activeIndex = Math.min(active, Math.max(0, matches.length - 1));\n\n  const {\n    refs: { setFloating, setPositionReference },\n    floatingStyles,\n  } = useFloating({\n    open: listOpen,\n    placement: \"bottom-start\",\n    strategy: \"fixed\",\n    whileElementsMounted: autoUpdate,\n    middleware: [\n      offset({ mainAxis: 6, crossAxis: -8 }),\n      flip({ padding: 8 }),\n      shift({ padding: 8 }),\n      size({ padding: 8, apply: ({ availableHeight, elements }) => elements.floating.style.setProperty(\"--avail\", `${Math.min(264, availableHeight)}px`) }),\n    ],\n  });\n  const setAnchor = useCallback((el: HTMLSpanElement | null) => setPositionReference(el), [setPositionReference]);\n\n  const refreshTrigger = (t: string, caret: number) => {\n    const found = users.length ? findTrigger(t, caret, findMentions(t, users)) : null;\n    setTrigger(found);\n    if (!found || found.at !== trigger?.at) setActive(0);\n    if (!found) setDismissedAt(null);\n  };\n\n  const pick = (user: ComposerUser) => {\n    const el = areaRef.current;\n    if (!el || !trigger) return;\n    const caret = el.selectionStart;\n    const after = text.slice(caret);\n    replaceRange(el, `@${user.name}${after.startsWith(\" \") ? \"\" : \" \"}`, trigger.at, caret);\n    setTrigger(null);\n    setAnnouncement(`${user.name} will be notified`);\n  };\n\n  /* ----- formatting ----- */\n  const wrap = (before: string, after = before) => {\n    const el = areaRef.current;\n    if (!el || busy) return;\n    const { selectionStart: s, selectionEnd: e, value } = el;\n    const selected = value.slice(s, e);\n    // Pressing it again on wrapped text unwraps it.\n    if (value.slice(s - before.length, s) === before && value.slice(e, e + after.length) === after) {\n      replaceRange(el, selected, s - before.length, e + after.length);\n      el.setSelectionRange(s - before.length, e - before.length);\n      return;\n    }\n    replaceRange(el, before + selected + after, s, e);\n    el.setSelectionRange(s + before.length, s + before.length + selected.length);\n  };\n\n  const startMention = () => {\n    const el = areaRef.current;\n    if (!el || busy) return;\n    const s = el.selectionStart;\n    const needsSpace = s > 0 && !/\\s/.test(el.value[s - 1]);\n    replaceRange(el, needsSpace ? \" @\" : \"@\", s, el.selectionEnd);\n  };\n\n  /* ----- send ----- */\n  const submit = async () => {\n    const body = text.trim();\n    if (!body || over || busy || disabled) return;\n    setPhase(\"sending\");\n    setError(null);\n    setTrigger(null);\n    window.clearTimeout(sentTimer.current);\n    try {\n      await onSubmit({ body, mentions: findMentions(body, users) });\n      setFlight((f) => ({ id: (f?.id ?? 0) + 1, text }));\n      setText(\"\");\n      if (draftStorageKey) storage.write(draftStorageKey, \"\");\n      setPhase(\"sent\");\n      setAnnouncement(\"Comment posted\");\n      sentTimer.current = window.setTimeout(() => {\n        setPhase(\"idle\");\n        if (document.activeElement !== areaRef.current) {\n          growRef.current = true;\n          setOpenState(false);\n        }\n      }, 1400);\n    } catch {\n      setPhase(\"idle\");\n      setError(\"Couldn’t post your comment. Try again.\");\n      setAnnouncement(\"Couldn’t post your comment\");\n    }\n  };\n\n  /* ----- mirror: the visible text, with mentions drawn as tokens behind a transparent textarea ----- */\n  const segments = useMemo(() => {\n    const out: { text: string; mention?: boolean; anchor?: boolean }[] = [];\n    const anchorAt = listOpen && trigger ? trigger.at : -1;\n    let at = 0;\n    const push = (t: string) => {\n      if (anchorAt >= at && anchorAt < at + t.length) {\n        const i = anchorAt - at;\n        if (i > 0) out.push({ text: t.slice(0, i) });\n        out.push({ text: \"@\", anchor: true });\n        if (t.length > i + 1) out.push({ text: t.slice(i + 1) });\n      } else if (t) out.push({ text: t });\n      at += t.length;\n    };\n    for (const m of mentions) {\n      push(text.slice(at, m.start));\n      out.push({ text: text.slice(m.start, m.end), mention: true });\n      at = m.end;\n    }\n    push(text.slice(at));\n    return out;\n  }, [text, mentions, listOpen, trigger]);\n\n  const notified = [...new Map(mentions.map((m) => [m.id, m.name])).values()];\n  const shared = \"whitespace-pre-wrap break-words px-3 py-2 text-base leading-5 sm:text-[13px] [overflow-wrap:anywhere]\";\n  const kbdMod = apple ? \"⌘\" : \"Ctrl\";\n\n  return (\n    <div className={cn(\"flex w-full min-w-0 items-start gap-2.5\", className)} data-state={expanded ? \"open\" : \"closed\"} data-disabled={disabled || undefined} {...rest}>\n      {author && (\n        <span\n          aria-hidden\n          className=\"mt-1.5 grid size-6 shrink-0 select-none place-items-center overflow-hidden rounded-full bg-hover text-[10px] font-medium text-fg-2 shadow-[inset_0_0_0_1px_var(--line-2)]\"\n        >\n          {author.avatarUrl ? (\n            // eslint-disable-next-line @next/next/no-img-element\n            <img src={author.avatarUrl} alt=\"\" className=\"size-full object-cover\" />\n          ) : (\n            initials(author.name)\n          )}\n        </span>\n      )}\n\n      <div className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n        <div\n          onMouseDown={(e) => {\n            // A press anywhere on the field's padding or toolbar gaps puts the caret in the text.\n            if (e.target === e.currentTarget) {\n              e.preventDefault();\n              areaRef.current?.focus();\n            }\n          }}\n          className={cn(\n            \"relative min-w-0 rounded-xl border bg-raised shadow-[var(--shadow)]\",\n            \"transition-[border-color,box-shadow] duration-150 ease-out\",\n            \"focus-within:ring-3 focus-within:ring-fg/8\",\n            error || over ? \"border-danger/60\" : expanded ? \"border-fg-4/70 focus-within:border-fg-4\" : \"border-line-2 hover:border-fg-4 focus-within:border-fg-4\",\n            disabled && \"pointer-events-none opacity-50\",\n          )}\n        >\n          <motion.div style={{ height }} className=\"relative overflow-hidden rounded-t-xl\">\n            {/* The mirror sets the height and draws the text; the textarea sits over it with transparent ink. */}\n            <div\n              ref={mirrorRef}\n              aria-hidden\n              className={cn(\n                shared,\n                \"pointer-events-none overflow-hidden text-fg\",\n                expanded ? \"min-h-[76px] max-h-60\" : \"max-h-9 min-h-9\",\n                busy && \"opacity-50 transition-opacity delay-150 duration-200\",\n                !expanded && text && \"pr-16\",\n              )}\n            >\n              {segments.map((seg, i) =>\n                seg.mention ? (\n                  <span key={i} className=\"rounded-[4px] bg-fg/10 text-fg shadow-[0_0_0_1px_color-mix(in_oklab,var(--fg)_10%,transparent)] [box-decoration-break:clone]\">\n                    {seg.text}\n                  </span>\n                ) : seg.anchor ? (\n                  <span key={i} ref={setAnchor}>\n                    @\n                  </span>\n                ) : (\n                  <span key={i}>{seg.text}</span>\n                ),\n              )}\n              {\"​\"}\n            </div>\n\n            <textarea\n              ref={areaRef}\n              value={text}\n              rows={1}\n              disabled={disabled}\n              readOnly={busy}\n              autoFocus={autoFocus}\n              placeholder={placeholder}\n              aria-label={placeholder.replace(/…$/, \"\")}\n              aria-invalid={over || !!error || undefined}\n              aria-describedby={cn(error ? errorId : \"\", hintId) || undefined}\n              aria-keyshortcuts={`${apple ? \"Meta\" : \"Control\"}+Enter`}\n              enterKeyHint=\"enter\"\n              role={users.length ? \"combobox\" : undefined}\n              aria-autocomplete={users.length ? \"list\" : undefined}\n              aria-expanded={users.length ? listOpen : undefined}\n              aria-controls={listOpen ? listId : undefined}\n              aria-activedescendant={listOpen && matches[activeIndex] ? `${listId}-${matches[activeIndex].id}` : undefined}\n              onFocus={() => setOpen(true)}\n              onBlur={() => {\n                setTrigger(null);\n                if (empty && phase === \"idle\") setOpen(false);\n              }}\n              onChange={(e) => {\n                setText(e.target.value);\n                setError(null);\n                if (!open) setOpen(true);\n                refreshTrigger(e.target.value, e.target.selectionStart ?? e.target.value.length);\n              }}\n              onSelect={(e) => {\n                const el = e.currentTarget;\n                if (el.selectionStart === el.selectionEnd) refreshTrigger(el.value, el.selectionStart);\n                else setTrigger(null);\n              }}\n              onScroll={(e) => {\n                if (mirrorRef.current) mirrorRef.current.scrollTop = e.currentTarget.scrollTop;\n              }}\n              onKeyDown={(e) => {\n                if (e.nativeEvent.isComposing) return;\n                const mod = e.metaKey || e.ctrlKey;\n                if (listOpen && matches.length) {\n                  if (e.key === \"ArrowDown\" || (e.key === \"n\" && e.ctrlKey)) {\n                    e.preventDefault();\n                    setActive((activeIndex + 1) % matches.length);\n                    return;\n                  }\n                  if (e.key === \"ArrowUp\" || (e.key === \"p\" && e.ctrlKey)) {\n                    e.preventDefault();\n                    setActive((activeIndex - 1 + matches.length) % matches.length);\n                    return;\n                  }\n                  if ((e.key === \"Enter\" && !mod) || e.key === \"Tab\") {\n                    e.preventDefault();\n                    pick(matches[activeIndex]);\n                    return;\n                  }\n                }\n                if (e.key === \"Escape\") {\n                  e.preventDefault();\n                  if (listOpen) setDismissedAt(trigger?.at ?? null);\n                  else if (open) {\n                    // Folds back to one line and keeps the draft; focus stays so typing reopens it.\n                    setOpen(false);\n                    e.currentTarget.scrollTop = 0;\n                  }\n                  return;\n                }\n                if (e.key === \"Enter\" && mod) {\n                  e.preventDefault();\n                  void submit();\n                  return;\n                }\n                if (mod && !e.shiftKey && !e.altKey) {\n                  const k = e.key.toLowerCase();\n                  if (k === \"b\" || k === \"i\" || k === \"e\") {\n                    e.preventDefault();\n                    wrap(k === \"b\" ? \"**\" : k === \"i\" ? \"_\" : \"`\");\n                    return;\n                  }\n                }\n                // A mention is removed whole: Backspace at its end takes the name, not one letter.\n                const el = e.currentTarget;\n                if (e.key === \"Backspace\" && el.selectionStart === el.selectionEnd) {\n                  const hit = mentions.find((m) => el.selectionStart === m.end);\n                  if (hit) {\n                    e.preventDefault();\n                    replaceRange(el, \"\", hit.start, hit.end);\n                  }\n                }\n              }}\n              className={cn(\n                shared,\n                \"absolute inset-0 block size-full resize-none overflow-y-auto bg-transparent text-transparent caret-fg outline-none placeholder:text-fg-4\",\n                \"selection:bg-fg/20 selection:text-transparent [scrollbar-width:thin]\",\n                !expanded && \"overflow-hidden\",\n                !expanded && text && \"pr-16\",\n                busy && \"cursor-progress\",\n              )}\n            />\n\n            {/* Collapsed with words in it: say it's a draft, so nobody thinks it was sent. */}\n            <AnimatePresence initial={false}>\n              {!expanded && text && (\n                <motion.span\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0, transition: { duration: 0.08 } }}\n                  className=\"pointer-events-none absolute right-3 top-2 font-mono text-[10.5px] uppercase leading-5 tracking-[0.08em] text-fg-3\"\n                >\n                  Draft\n                </motion.span>\n              )}\n            </AnimatePresence>\n\n            {/* Sent: the words lift away toward the thread above while the field empties. */}\n            <AnimatePresence>\n              {flight && (\n                <motion.div\n                  key={flight.id}\n                  aria-hidden\n                  initial={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  animate={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: \"blur(3px)\" }}\n                  // It rises on the expo curve but fades on ease-in, so the words are seen leaving, not blinking out.\n                  transition={reduce ? { duration: 0.15 } : { duration: 0.34, ease: ease.out, opacity: { duration: 0.3, ease: ease.in } }}\n                  onAnimationComplete={() => setFlight(null)}\n                  className={cn(shared, \"pointer-events-none absolute inset-x-0 top-0 text-fg\")}\n                >\n                  {flight.text}\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </motion.div>\n\n          <AnimatePresence initial={false}>\n            {expanded && (\n              <motion.div\n                key=\"toolbar\"\n                initial={{ height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={{ height: 0, opacity: 0, transition: { height: { duration: reduce ? 0 : 0.18, ease: ease.inOut }, opacity: { duration: 0.08 } } }}\n                transition={reduce ? { duration: 0.12, height: { duration: 0 } } : { height: { duration: 0.24, ease: ease.inOut }, opacity: { duration: 0.18, delay: 0.05 } }}\n                className=\"overflow-hidden\"\n              >\n                <AnimatePresence initial={false}>\n                  {notified.length > 0 && (\n                    <motion.p\n                      key=\"notify\"\n                      initial={{ height: 0, opacity: 0 }}\n                      animate={{ height: \"auto\", opacity: 1 }}\n                      exit={{ height: 0, opacity: 0 }}\n                      transition={{ duration: reduce ? 0 : 0.2, ease: ease.inOut }}\n                      className=\"overflow-hidden px-3 text-[12px] text-fg-3\"\n                    >\n                      <span className=\"flex items-center gap-1.5 pb-1.5\" suppressHydrationWarning>\n                        <AtSign size={12} className=\"shrink-0\" />\n                        <span className=\"min-w-0 truncate\">\n                          Notifies <span className=\"text-fg-2\">{listFormat ? listFormat.format(notified) : notified.join(\", \")}</span>\n                        </span>\n                      </span>\n                    </motion.p>\n                  )}\n                </AnimatePresence>\n\n                <div className=\"flex min-w-0 items-center gap-2 border-t border-line py-1.5 pl-1.5 pr-1.5\">\n                  <Toolbar.Root aria-label=\"Formatting\" className=\"flex items-center gap-0.5\">\n                    <ToolButton label=\"Bold\" keys={[kbdMod, \"B\"]} onPress={() => wrap(\"**\")} disabled={busy}>\n                      <BoldIcon />\n                    </ToolButton>\n                    <ToolButton label=\"Italic\" keys={[kbdMod, \"I\"]} onPress={() => wrap(\"_\")} disabled={busy}>\n                      <ItalicIcon />\n                    </ToolButton>\n                    <ToolButton label=\"Code\" keys={[kbdMod, \"E\"]} onPress={() => wrap(\"`\")} disabled={busy}>\n                      <Code />\n                    </ToolButton>\n                    {users.length > 0 && (\n                      <>\n                        <Toolbar.Separator className=\"mx-1 h-4 w-px bg-line-2\" />\n                        <ToolButton label=\"Mention someone\" keys={[\"@\"]} onPress={startMention} disabled={busy}>\n                          <AtSign />\n                        </ToolButton>\n                      </>\n                    )}\n                  </Toolbar.Root>\n\n                  <div className=\"ml-auto flex shrink-0 items-center gap-2\">\n                    {maxLength != null && nearLimit && (\n                      <span className={cn(\"font-mono text-[11px] tabular\", over ? \"text-danger\" : \"text-fg-3\")} aria-live=\"polite\">\n                        {length}/{maxLength}\n                      </span>\n                    )}\n                    <SendButton\n                      phase={phase}\n                      label={submitLabel}\n                      keys={[kbdMod, \"↵\"]}\n                      disabled={empty || over || disabled}\n                      onPress={() => void submit()}\n                      reduce={reduce}\n                    />\n                  </div>\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <AnimatePresence initial={false}>\n          {error && (\n            <motion.p\n              id={errorId}\n              role=\"alert\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.18, ease: ease.out }}\n              className=\"px-3 text-[12px] text-danger\"\n            >\n              {error}\n            </motion.p>\n          )}\n        </AnimatePresence>\n        <span id={hintId} className=\"sr-only\">\n          {`Press ${apple ? \"Command\" : \"Control\"} Enter to send.${users.length ? \" Type @ to mention someone.\" : \"\"}`}\n        </span>\n      </div>\n\n      {listOpen && (\n        <FloatingPortal root={portalContainer ?? undefined}>\n          <div ref={setFloating} style={floatingStyles} className=\"z-(--z-popover) w-[min(17rem,calc(100vw-16px))]\">\n            {/* It opens from typing, many times a day, so it only fades and settles 2px: 100ms. */}\n            <motion.div\n              id={listId}\n              role=\"listbox\"\n              aria-label=\"People\"\n              initial={{ opacity: 0, y: reduce ? 0 : -2 }}\n              animate={{ opacity: 1, y: 0 }}\n              transition={{ duration: 0.1, ease: ease.out }}\n              onMouseDown={(e) => e.preventDefault()}\n              className=\"max-h-(--avail) overflow-y-auto overscroll-contain rounded-xl border border-line-2 bg-raised p-1 shadow-pop\"\n            >\n              {matches.length === 0 ? (\n                <p className=\"px-2.5 py-2 text-[12.5px] text-fg-3\">No one called “{trigger?.query}”</p>\n              ) : (\n                matches.map((u, i) => (\n                  <div\n                    key={u.id}\n                    id={`${listId}-${u.id}`}\n                    role=\"option\"\n                    aria-selected={i === activeIndex}\n                    onMouseMove={() => i !== activeIndex && setActive(i)}\n                    onClick={() => pick(u)}\n                    className=\"flex h-10 cursor-default select-none items-center gap-2.5 rounded-lg px-2 aria-selected:bg-fg/[0.06] pointer-coarse:h-11\"\n                  >\n                    <span aria-hidden className=\"grid size-6 shrink-0 place-items-center overflow-hidden rounded-full bg-fg/10 text-[10px] font-medium text-fg-2\">\n                      {u.avatarUrl ? (\n                        // eslint-disable-next-line @next/next/no-img-element\n                        <img src={u.avatarUrl} alt=\"\" className=\"size-full object-cover\" />\n                      ) : (\n                        initials(u.name)\n                      )}\n                    </span>\n                    <span className=\"flex min-w-0 flex-col\">\n                      <span className=\"truncate text-[13px] leading-4 text-fg\">\n                        <Highlight text={u.name} query={trigger?.query ?? \"\"} />\n                      </span>\n                      {u.detail && <span className=\"truncate text-[11.5px] leading-4 text-fg-3\">{u.detail}</span>}\n                    </span>\n                  </div>\n                ))\n              )}\n            </motion.div>\n          </div>\n        </FloatingPortal>\n      )}\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {listOpen ? (matches.length ? `${matches.length} ${matches.length === 1 ? \"person\" : \"people\"} found` : \"No matches\") : announcement}\n      </span>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Parts\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const i = query ? text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) : -1;\n  if (i < 0) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, i)}\n      <span className=\"font-medium\">{text.slice(i, i + query.length)}</span>\n      {text.slice(i + query.length)}\n    </>\n  );\n}\n\nfunction Keys({ keys, className }: { keys: string[]; className?: string }) {\n  return (\n    <kbd className={cn(\"flex items-center gap-0.5 font-sans\", className)}>\n      {keys.map((k) => (\n        <span key={k} className={cn(\"inline-flex h-4 min-w-4 items-center justify-center rounded-[4px] px-1 text-[10.5px] leading-none\", k.length > 1 && \"font-mono\")}>\n          {k}\n        </span>\n      ))}\n    </kbd>\n  );\n}\n\nfunction ToolButton({\n  label,\n  keys,\n  onPress,\n  disabled,\n  children,\n}: {\n  label: string;\n  keys: string[];\n  onPress: () => void;\n  disabled?: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger\n        delay={500}\n        render={\n          <Toolbar.Button\n            aria-label={label}\n            disabled={disabled}\n            // Keeps the caret and the selection in the text while the button is pressed.\n            onMouseDown={(e: React.MouseEvent) => e.preventDefault()}\n            onClick={onPress}\n            className={cn(\n              \"relative grid size-7 place-items-center rounded-md text-fg-3 outline-none\",\n              \"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-fg/[0.06] hover:text-fg active:scale-[0.9] active:duration-75\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n              \"data-disabled:opacity-40 data-disabled:hover:bg-transparent\",\n              \"before:absolute before:-inset-x-1 before:-inset-y-2 before:content-[''] pointer-fine:before:hidden\",\n            )}\n          >\n            {children}\n          </Toolbar.Button>\n        }\n      />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"top\" sideOffset={6} 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 pr-1.5 text-[12px] leading-4 text-fg shadow-pop\",\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 keys={keys} className=\"text-fg-3 [&>span]:border [&>span]:border-line-2 [&>span]:bg-frame\" />\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\nfunction SendButton({\n  phase,\n  label,\n  keys,\n  disabled,\n  onPress,\n  reduce,\n}: {\n  phase: Phase;\n  label: string;\n  keys: string[];\n  disabled: boolean;\n  onPress: () => void;\n  reduce: boolean;\n}) {\n  const enter = reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" };\n  const leave = reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" };\n  const resting = (\n    <span className=\"flex items-center gap-1.5\">\n      {label}\n      <kbd className=\"font-sans text-[11px] tracking-[0.04em] text-frame/55 pointer-coarse:hidden\">{keys.join(\"\")}</kbd>\n    </span>\n  );\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onPress}\n      disabled={disabled && phase === \"idle\"}\n      aria-busy={phase === \"sending\" || undefined}\n      aria-disabled={phase !== \"idle\" || undefined}\n      className={cn(\n        \"relative inline-flex h-7 shrink-0 select-none items-center justify-center overflow-hidden rounded-md bg-fg px-2.5 text-[12.5px] font-medium text-frame outline-none\",\n        \"transition-[background-color,opacity,scale] duration-150 ease-out-quart hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"disabled:opacity-35 disabled:hover:bg-fg aria-busy:cursor-progress\",\n        \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n      )}\n    >\n      {/* Every state is laid out in the same cell, so the button never changes width. */}\n      <span className=\"grid place-items-center\">\n        <span aria-hidden className=\"invisible col-start-1 row-start-1\">\n          {resting}\n        </span>\n        <span aria-hidden className=\"invisible col-start-1 row-start-1 flex items-center gap-1.5\">\n          <Check size={14} />\n          Posted\n        </span>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={phase}\n            className=\"col-start-1 row-start-1 flex items-center gap-1.5\"\n            initial={enter}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={leave}\n            transition={reduce ? { duration: 0.12 } : phase === \"sent\" ? spring.pop : { duration: 0.2, ease: ease.out }}\n          >\n            {phase === \"idle\" ? (\n              resting\n            ) : phase === \"sending\" ? (\n              <>\n                <Loader size={14} className=\"animate-spin\" />\n                <span className=\"sr-only\">Posting</span>\n              </>\n            ) : (\n              <>\n                <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n                  <motion.path\n                    d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                    initial={reduce ? false : { pathLength: 0 }}\n                    animate={{ pathLength: 1 }}\n                    transition={{ duration: 0.3, ease: ease.out, delay: 0.05 }}\n                  />\n                </svg>\n                Posted\n              </>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/comment-composer"
}
