{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"paste-pills","type":"registry:ui","title":"PastePills","description":"The paste-pill core Composer, PromptInput and Input share: hold a long paste or a pasted image as a pill, and post pasted files with the form.","package":"react-fancy","dependencies":["tailwind-merge"],"registryDependencies":["popover","portal"],"files":[{"path":"components\/fancy\/paste-pills\/InlinePasteEditor.tsx","content":"import {\n  forwardRef,\n  useCallback,\n  useImperativeHandle,\n  useLayoutEffect,\n  useRef,\n} from \"react\";\nimport { cn } from \"..\/lib\/utils\/cn\";\nimport { PILL_MARK, pasteMarker, segmentValue } from \".\/paste-markers\";\n\n\/** The attribute on the placeholder span a pill is portalled into. *\/\nexport const HOST_ATTR = \"data-react-fancy-composer-paste-host\";\n\n\/** What a paste becomes, as decided by `Composer`. `block` pills get their own line. *\/\nexport interface PillToInsert {\n  id: string;\n  block: boolean;\n}\n\nexport interface InlinePasteEditorHandle {\n  focus(): void;\n  \/** Swap a pill for plain text, where it sits. *\/\n  replacePill(id: string, text: string): void;\n  \/**\n   * Replace the whole value and put the caret at `caret` (an offset into the\n   * value). For a host that edits text itself \u2014 `PromptInput`'s `\/` and `@`\n   * pickers \u2014 where rebuilding from `value` alone would lose the caret.\n   *\/\n  setValue(value: string, caret: number): void;\n}\n\ninterface InlinePasteEditorProps {\n  value: string;\n  \/** Ids the composer holds. A marker for anything else stays text. *\/\n  known: ReadonlySet<string>;\n  \/** `caret` is the caret's offset into `value`, or `value.length` if it is elsewhere. *\/\n  onChange(value: string, caret: number): void;\n  onSubmit(): void;\n  \/**\n   * Which key sends. `\"enter\"` (default): Enter sends, Shift+Enter is a newline.\n   * `\"mod-enter\"`: Ctrl\/\u2318+Enter sends and a plain Enter is a newline.\n   *\/\n  submitKey?: \"enter\" | \"mod-enter\";\n  \/** Runs BEFORE the editor's own key handling; `preventDefault()` to claim the key. *\/\n  onKeyDown?(e: React.KeyboardEvent<HTMLDivElement>): void;\n  placeholderClassName?: string;\n  \/** Height in px past which the input scrolls. Defaults to the class's max height. *\/\n  maxHeight?: number;\n  \/** Return the pills a paste becomes, or null to insert its text as typed. *\/\n  onPaste(data: DataTransfer | null): PillToInsert[] | null;\n  \/** Every pill placeholder currently in the editor, for the composer to portal into. *\/\n  onHostsChange(hosts: ReadonlyMap<string, HTMLElement>): void;\n  placeholder: string;\n  disabled: boolean;\n  className?: string;\n  \/**\n   * `false` makes it a single-line field, as `Input` uses it: Enter always\n   * submits, and a newline in pasted text becomes a space, as a browser does for\n   * an `<input>`. A held paste keeps its newlines; it is only the field that has\n   * one line. Default `true`.\n   *\/\n  multiline?: boolean;\n  \/** The editable surface's id, so a host can find it. *\/\n  id?: string;\n  \/** Names the textbox for assistive tech; a `<div>` cannot be the target of a `<label>`. *\/\n  ariaLabelledBy?: string;\n  ariaInvalid?: boolean;\n  style?: React.CSSProperties;\n}\n\n\/**\n * Walk a node's children back into a value string.\n *\n * `<br>` as the LAST child of its parent is filler \u2014 browsers put one in an empty\n * line, and this editor puts one after a trailing newline so the caret has a line\n * to sit on \u2014 so it counts for nothing. Block elements a browser invents count as\n * a line break before them. Non-breaking spaces become spaces: a browser inserts\n * them for typed spaces it fears would collapse, not because anyone asked.\n *\/\nfunction serialize(parent: Node, keep: (id: string) => boolean): string {\n  let out = \"\";\n  const children = Array.from(parent.childNodes);\n\n  children.forEach((node, i) => {\n    if (node.nodeType === Node.TEXT_NODE) {\n      out += (node.textContent ?? \"\").replace(\/\\u00A0\/g, \" \");\n      return;\n    }\n    if (!(node instanceof HTMLElement)) return;\n\n    const id = node.getAttribute(HOST_ATTR);\n    if (id !== null) {\n      if (keep(id)) out += pasteMarker(id);\n      return;\n    }\n    if (node.tagName === \"BR\") {\n      if (i !== children.length - 1) out += \"\\n\";\n      return;\n    }\n    if (\/^(DIV|P)$\/.test(node.tagName) && out.length > 0 && !out.endsWith(\"\\n\")) {\n      out += \"\\n\";\n    }\n    out += serialize(node, keep);\n  });\n\n  return out;\n}\n\nfunction makeHost(id: string): HTMLElement {\n  const host = document.createElement(\"span\");\n  host.setAttribute(\"contenteditable\", \"false\");\n  host.setAttribute(HOST_ATTR, id);\n  host.className = \"inline-block align-baseline\";\n  return host;\n}\n\n\/** A value ending in a newline needs a `<br>` after it, or the caret has no line to sit on. *\/\nfunction ensureTrailingBreak(root: HTMLElement, value: string) {\n  const last = root.lastChild;\n  const hasBreak = last instanceof HTMLElement && last.tagName === \"BR\";\n\n  if (value.endsWith(\"\\n\") && !hasBreak) root.append(document.createElement(\"br\"));\n  if (!value.endsWith(\"\\n\") && hasBreak && value.length > 0) last.remove();\n}\n\nfunction placeCaretAfter(node: Node) {\n  const selection = window.getSelection();\n  if (!selection) return;\n\n  const range = document.createRange();\n  range.setStartAfter(node);\n  range.collapse(true);\n  selection.removeAllRanges();\n  selection.addRange(range);\n}\n\n\/**\n * Put the caret `offset` characters into the serialised value. Walks the shape\n * `render` produces \u2014 text nodes, pill hosts (each worth its marker's length) and\n * a trailing `<br>` \u2014 so it is only called straight after a render.\n *\/\nfunction placeCaretAtOffset(root: HTMLElement, offset: number, keep: (id: string) => boolean) {\n  const selection = window.getSelection();\n  if (!selection) return;\n\n  const range = document.createRange();\n  let remaining = offset;\n  let placed = false;\n\n  for (const node of Array.from(root.childNodes)) {\n    if (node.nodeType === Node.TEXT_NODE) {\n      const length = node.textContent?.length ?? 0;\n      if (remaining <= length) {\n        range.setStart(node, remaining);\n        placed = true;\n        break;\n      }\n      remaining -= length;\n      continue;\n    }\n    const id = node instanceof HTMLElement ? node.getAttribute(HOST_ATTR) : null;\n    if (id !== null && keep(id)) {\n      const length = pasteMarker(id).length;\n      if (remaining < length) {\n        range.setStartAfter(node);\n        placed = true;\n        break;\n      }\n      remaining -= length;\n    }\n  }\n\n  if (!placed) {\n    range.selectNodeContents(root);\n    range.collapse(false);\n    const last = root.lastChild;\n    if (last instanceof HTMLElement && last.tagName === \"BR\") range.setStartBefore(last);\n  }\n  range.collapse(true);\n  selection.removeAllRanges();\n  selection.addRange(range);\n}\n\n\/**\n * The plain-text-plus-pills surface behind `Composer`'s inline placement.\n *\n * The DOM is the source of truth WHILE TYPING and `value` is its serialisation;\n * the DOM is rebuilt from `value` only when `value` changes from outside (a send,\n * a host, an agent). Rebuilding on every keystroke would throw the caret away.\n *\n * Every insertion is done here by hand, never by the browser: a paste is always\n * intercepted, so no foreign HTML ever lands in the editor, and Enter is handled\n * so a browser never invents a `<div>` per line.\n *\/\nexport const InlinePasteEditor = forwardRef<InlinePasteEditorHandle, InlinePasteEditorProps>(\n  function InlinePasteEditor(\n    {\n      value,\n      known,\n      onChange,\n      onSubmit,\n      onPaste,\n      onHostsChange,\n      placeholder,\n      disabled,\n      className,\n      submitKey = \"enter\",\n      onKeyDown,\n      placeholderClassName,\n      maxHeight,\n      multiline = true,\n      id,\n      ariaLabelledBy,\n      ariaInvalid,\n      style,\n    },\n    ref,\n  ) {\n    const rootRef = useRef<HTMLDivElement>(null);\n    \/** The last value this editor produced or rendered. Anything else came from outside. *\/\n    const lastValue = useRef<string | null>(null);\n    \/**\n     * Ids inserted here whose hold has not reached `known` yet. A pill is held and\n     * placed in the same event, and the composer's state lands a render later.\n     *\/\n    const pending = useRef(new Set<string>());\n    const knownRef = useRef(known);\n    knownRef.current = known;\n\n    const keep = useCallback(\n      (id: string) => knownRef.current.has(id) || pending.current.has(id),\n      [],\n    );\n\n    const reportHosts = useCallback(() => {\n      const root = rootRef.current;\n      if (!root) return;\n\n      const hosts = new Map<string, HTMLElement>();\n      root.querySelectorAll<HTMLElement>(`[${HOST_ATTR}]`).forEach((el) => {\n        const id = el.getAttribute(HOST_ATTR)!;\n        if (keep(id)) hosts.set(id, el);\n      });\n      onHostsChange(hosts);\n    }, [keep, onHostsChange]);\n\n    \/** The caret's offset into the serialised value, or its length if the caret is elsewhere. *\/\n    const caretOffset = useCallback(\n      (whole: string): number => {\n        const root = rootRef.current;\n        const selection = window.getSelection();\n        if (!root || !selection || selection.rangeCount === 0) return whole.length;\n\n        const range = selection.getRangeAt(0);\n        if (!root.contains(range.endContainer)) return whole.length;\n\n        const head = document.createRange();\n        head.selectNodeContents(root);\n        head.setEnd(range.endContainer, range.endOffset);\n        return serialize(head.cloneContents(), keep).length;\n      },\n      [keep],\n    );\n\n    \/** Read the DOM back into a value and tell the composer. *\/\n    const emit = useCallback(() => {\n      const root = rootRef.current;\n      if (!root) return;\n\n      const next = serialize(root, keep);\n      ensureTrailingBreak(root, next);\n      reportHosts();\n\n      \/\/ Reported even when the value is unchanged: a host reading the caret (a\n      \/\/ `\/` picker) needs to hear about it.\n      lastValue.current = next;\n      onChange(next, caretOffset(next));\n    }, [keep, onChange, reportHosts, caretOffset]);\n\n    \/** Rebuild the DOM from a value. Throws the caret away; callers put it back. *\/\n    const render = useCallback(\n      (next: string) => {\n        const root = rootRef.current;\n        if (!root) return;\n\n        root.replaceChildren();\n        for (const segment of segmentValue(next, knownRef.current)) {\n          if (segment.type === \"text\") {\n            root.append(document.createTextNode(segment.text));\n          } else {\n            root.append(makeHost(segment.id));\n          }\n        }\n        ensureTrailingBreak(root, next);\n        lastValue.current = next;\n        reportHosts();\n      },\n      [reportHosts],\n    );\n\n    \/** The caret's range, or the end of the editor if the caret is elsewhere. *\/\n    const caret = useCallback((): Range => {\n      const root = rootRef.current!;\n      const selection = window.getSelection();\n\n      if (selection && selection.rangeCount > 0) {\n        const range = selection.getRangeAt(0);\n        if (root.contains(range.commonAncestorContainer)) return range;\n      }\n\n      const range = document.createRange();\n      range.selectNodeContents(root);\n      range.collapse(false);\n      const last = root.lastChild;\n      if (last instanceof HTMLElement && last.tagName === \"BR\") {\n        range.setStartBefore(last);\n        range.collapse(true);\n      }\n      return range;\n    }, []);\n\n    \/** Replace the selection with `nodes` and leave the caret after them. *\/\n    const insert = useCallback(\n      (build: (before: string, after: string) => Node[]) => {\n        const root = rootRef.current!;\n        const range = caret();\n        range.deleteContents();\n\n        const head = document.createRange();\n        head.selectNodeContents(root);\n        head.setEnd(range.startContainer, range.startOffset);\n        const tail = document.createRange();\n        tail.selectNodeContents(root);\n        tail.setStart(range.endContainer, range.endOffset);\n\n        const nodes = build(\n          serialize(head.cloneContents(), () => true),\n          serialize(tail.cloneContents(), () => true),\n        );\n        if (nodes.length === 0) return;\n\n        const fragment = document.createDocumentFragment();\n        nodes.forEach((n) => fragment.append(n));\n        range.insertNode(fragment);\n        placeCaretAfter(nodes[nodes.length - 1]);\n        emit();\n      },\n      [caret, emit],\n    );\n\n    const insertText = useCallback(\n      (text: string) => insert(() => (text ? [document.createTextNode(text)] : [])),\n      [insert],\n    );\n\n    \/*\n     * Rebuild from `value` when it changed from outside \u2014 or when `known` now\n     * disagrees with the pills on screen, e.g. a host set a marker and then the\n     * paste it names. Never on our own edits: that would discard the caret.\n     *\/\n    useLayoutEffect(() => {\n      const root = rootRef.current;\n      if (!root) return;\n\n      for (const id of pending.current) if (known.has(id)) pending.current.delete(id);\n\n      const wanted = segmentValue(value, known)\n        .flatMap((s) => (s.type === \"pill\" ? [s.id] : []))\n        .join(PILL_MARK);\n      const onScreen = Array.from(root.querySelectorAll(`[${HOST_ATTR}]`))\n        .map((el) => el.getAttribute(HOST_ATTR))\n        .filter((id): id is string => id !== null && keep(id))\n        .join(PILL_MARK);\n\n      if (value === lastValue.current && wanted === onScreen) return;\n\n      render(value);\n    }, [value, known, keep, render]);\n\n    useImperativeHandle(\n      ref,\n      () => ({\n        focus() {\n          const root = rootRef.current;\n          if (!root) return;\n          root.focus();\n          const range = caret();\n          const selection = window.getSelection();\n          selection?.removeAllRanges();\n          selection?.addRange(range);\n        },\n        replacePill(id, text) {\n          const root = rootRef.current;\n          const host = Array.from(\n            root?.querySelectorAll(`[${HOST_ATTR}]`) ?? [],\n          ).find((el) => el.getAttribute(HOST_ATTR) === id);\n          if (!root || !host) return;\n\n          const node = document.createTextNode(text);\n          host.replaceWith(node);\n          root.focus();\n          placeCaretAfter(node);\n          emit();\n        },\n        setValue(next, offset) {\n          const root = rootRef.current;\n          if (!root) return;\n\n          render(next);\n          root.focus();\n          placeCaretAtOffset(root, offset, keep);\n          emit();\n        },\n      }),\n      [caret, emit, render, keep],\n    );\n\n    const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {\n      \/\/ ALWAYS intercepted: a browser left to paste into a contenteditable inserts\n      \/\/ the clipboard's HTML, styles and all.\n      e.preventDefault();\n      if (disabled) return;\n\n      const pills = onPaste(e.clipboardData);\n\n      if (pills === null) {\n        const text = (e.clipboardData?.getData(\"text\/plain\") ?? \"\").replace(\/\\r\\n?\/g, \"\\n\");\n        insertText(multiline ? text : text.replace(\/\\n+\/g, \" \"));\n        return;\n      }\n\n      for (const { id } of pills) pending.current.add(id);\n\n      insert((before, after) => {\n        const nodes: Node[] = [];\n\n        for (const { id, block } of pills) {\n          if (block) {\n            \/\/ A text pill gets a line to itself: a newline above unless it is\n            \/\/ already at the start of one, and below unless one follows.\n            const precededByBreak =\n              nodes.length === 0\n                ? before === \"\" || before.endsWith(\"\\n\")\n                : nodes[nodes.length - 1].textContent === \"\\n\";\n            if (!precededByBreak) nodes.push(document.createTextNode(\"\\n\"));\n            nodes.push(makeHost(id));\n            nodes.push(document.createTextNode(\"\\n\"));\n          } else {\n            nodes.push(makeHost(id));\n          }\n        }\n\n        \/\/ The trailing newline of a block pill is redundant if one already follows.\n        const last = nodes[nodes.length - 1];\n        if (last.textContent === \"\\n\" && after.startsWith(\"\\n\")) nodes.pop();\n\n        return nodes;\n      });\n    };\n\n    const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n      onKeyDown?.(e);\n      if (e.defaultPrevented) return;\n      if (e.key !== \"Enter\" || e.nativeEvent.isComposing) return;\n\n      e.preventDefault();\n      const sends =\n        !multiline || (submitKey === \"mod-enter\" ? e.ctrlKey || e.metaKey : !e.shiftKey);\n      if (sends) {\n        onSubmit();\n      } else {\n        insertText(\"\\n\");\n      }\n    };\n\n    return (\n      <div className=\"relative\">\n        {value === \"\" && (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute left-4 top-3 text-sm text-zinc-400\",\n              placeholderClassName,\n            )}\n          >\n            {placeholder}\n          <\/span>\n        )}\n        <div\n          ref={rootRef}\n          id={id}\n          data-react-fancy-composer-input=\"\"\n          role=\"textbox\"\n          aria-multiline={multiline}\n          aria-labelledby={ariaLabelledBy}\n          aria-invalid={ariaInvalid || undefined}\n          aria-placeholder={placeholder}\n          aria-disabled={disabled || undefined}\n          contentEditable={!disabled}\n          suppressContentEditableWarning\n          onInput={emit}\n          onPaste={handlePaste}\n          onKeyDown={handleKeyDown}\n          style={maxHeight === undefined ? style : { ...style, maxHeight }}\n          className={cn(\n            \"max-h-72 min-h-[4.5rem] w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent px-4 pt-3 text-sm outline-none\",\n            className,\n          )}\n        \/>\n      <\/div>\n    );\n  },\n);\n","type":"registry:ui","target":"components\/fancy\/paste-pills\/InlinePasteEditor.tsx"},{"path":"components\/fancy\/paste-pills\/InlinePill.tsx","content":"import { useEffect, useState, type ReactNode } from \"react\";\nimport { cn } from \"..\/lib\/utils\/cn\";\nimport { Popover } from \"..\/popover\";\nimport type { ComposerPasteClassNames, ComposerPastePreview } from \".\/PastePills.types\";\n\n\/**\n * Thousands separators, written out rather than left to `toLocaleString()`.\n *\n * The number exists so someone can size the paste at a glance, and `2,036` sizes\n * faster than `2036`. It is not left to the runtime locale because the label beside\n * it (\"Pasted text\") is not localised either, so a locale-formatted number next to\n * an English noun would be half a translation.\n *\/\nexport function withSeparators(n: number): string {\n  return String(n).replace(\/\\B(?=(\\d{3})+(?!\\d))\/g, \",\");\n}\n\n\/** First and last N characters, middle elided. The ends say WHICH paste this is. *\/\nexport function endsOf(text: string, chars: number): string {\n  if (text.length <= chars * 2) return text;\n\n  return `${text.slice(0, chars)} \u2026 ${text.slice(-chars)}`;\n}\n\nconst PILL_BASE =\n  \"inline-flex select-none items-center rounded-md px-2 py-0.5 text-xs leading-5 transition-colors\";\nconst TEXT_PILL =\n  \"cursor-pointer bg-[color:var(--fancy-surface-muted,var(--color-zinc-100))] text-zinc-700 hover:bg-zinc-200 dark:bg-[color:var(--fancy-surface-muted-dark,var(--color-zinc-800))] dark:text-zinc-200 dark:hover:bg-zinc-700\";\nconst IMAGE_PILL =\n  \"bg-rose-100 font-medium text-rose-600 dark:bg-rose-500\/20 dark:text-rose-300\";\n\/** Merged onto the Popover panel's own defaults: tighter padding for a preview. *\/\nconst PREVIEW_BASE = \"max-w-xs whitespace-pre-wrap break-all p-2 text-xs\";\n\n\/**\n * An image's thumbnail, made from the File only while the preview is open.\n *\n * If the image cannot be shown \u2014 a Content-Security-Policy without `blob:` in\n * `img-src` is the usual reason \u2014 the label is shown instead. A broken `<img>`\n * with an empty `alt` renders as nothing at all, which is how a preview ends up\n * as an empty box.\n *\/\nfunction ImageThumb({ file, label }: { file: File; label: string }) {\n  const [url, setUrl] = useState<string | null>(null);\n  const [failed, setFailed] = useState(false);\n\n  useEffect(() => {\n    if (typeof URL.createObjectURL !== \"function\") return;\n    const made = URL.createObjectURL(file);\n    setUrl(made);\n    setFailed(false);\n    return () => URL.revokeObjectURL(made);\n  }, [file]);\n\n  return url && !failed ? (\n    <img\n      src={url}\n      alt={label}\n      onError={() => setFailed(true)}\n      className=\"block max-h-40 max-w-[16rem] rounded\"\n    \/>\n  ) : (\n    <span>{label}<\/span>\n  );\n}\n\ninterface InlinePillProps {\n  preview: ComposerPastePreview;\n  previewChars: number;\n  renderPastePreview?: (preview: ComposerPastePreview) => ReactNode;\n  classNames?: ComposerPasteClassNames;\n  disabled: boolean;\n  onExpand: (id: string) => void;\n}\n\n\/**\n * One pill inside the message, portalled by `Composer` into a placeholder the\n * editor made. Internal \u2014 its own file so its props are not read as `Composer`'s.\n *\n * The preview is a hover `Popover` (portalled, so the scrolling input cannot clip\n * it), and it opens on FOCUS too, so a keyboard user reaches the same preview. It\n * was a `Tooltip` until 5.51.0; a tooltip is for a short label, not an image.\n *\/\nexport function InlinePill({\n  preview,\n  previewChars,\n  renderPastePreview,\n  classNames,\n  disabled,\n  onExpand,\n}: InlinePillProps) {\n  const [open, setOpen] = useState(false);\n  const id = preview.kind === \"text\" ? preview.paste.id : preview.image.id;\n  const content = (\n    <span data-react-fancy-composer-paste-pill-preview={id}>\n      {renderPastePreview?.(preview) ??\n        (preview.kind === \"text\" ? (\n          endsOf(preview.paste.text, previewChars)\n        ) : (\n          <ImageThumb file={preview.image.file} label={preview.image.label} \/>\n        ))}\n    <\/span>\n  );\n  const focusOpen = {\n    onFocus: () => setOpen(true),\n    onBlur: () => setOpen(false),\n  };\n\n  const pill =\n    preview.kind === \"image\" ? (\n      <span\n        data-react-fancy-composer-image-pill={id}\n        role=\"img\"\n        tabIndex={0}\n        aria-label={preview.image.label}\n        {...focusOpen}\n        className={cn(PILL_BASE, IMAGE_PILL, classNames?.pill, classNames?.imagePill)}\n      >\n        {preview.image.label}\n      <\/span>\n    ) : (\n      <button\n        type=\"button\"\n        data-react-fancy-composer-paste-pill={id}\n        onClick={() => onExpand(id)}\n        disabled={disabled}\n        aria-label={`Insert this pasted text, ${withSeparators(preview.paste.text.length)} characters`}\n        {...focusOpen}\n        className={cn(PILL_BASE, TEXT_PILL, classNames?.pill, classNames?.textPill)}\n      >\n        Pasted text \u00b7 {withSeparators(preview.paste.text.length)} characters\n      <\/button>\n    );\n\n  return (\n    <Popover hover open={open} onOpenChange={setOpen} placement=\"top\">\n      <Popover.Trigger>{pill}<\/Popover.Trigger>\n      <Popover.Content className={cn(PREVIEW_BASE, classNames?.preview)}>\n        {content}\n      <\/Popover.Content>\n    <\/Popover>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/paste-pills\/InlinePill.tsx"},{"path":"components\/fancy\/paste-pills\/PastePills.types.ts","content":"import type { HeldPaste } from \".\/use-paste-pills\";\nimport type { HeldImage } from \".\/paste-markers\";\n\nexport type { HeldPaste, HeldImage };\n\n\/** What a hover preview is being asked to show. *\/\nexport type ComposerPastePreview =\n  | { kind: \"text\"; paste: HeldPaste }\n  | { kind: \"image\"; image: HeldImage };\n\n\/**\n * Classes for the paste pills, MERGED onto the defaults rather than replacing them,\n * so a restyle can be one class or a whole new look.\n *\/\nexport interface ComposerPasteClassNames {\n  \/** Every pill, text or image. *\/\n  pill?: string;\n  \/** Text pills only \u2014 `Pasted text \u00b7 669 characters`. *\/\n  textPill?: string;\n  \/** Image pills only \u2014 `Pasted image 1`. *\/\n  imagePill?: string;\n  \/** The hover popover. *\/\n  preview?: string;\n  \/** The \u00d7 discard control (beside-the-input placement only; inline pills are backspaced). *\/\n  remove?: string;\n  \/** The editable surface itself. *\/\n  input?: string;\n}\n\n\/** The same shapes under the names they have outside `Composer`. *\/\nexport type PastePreview = ComposerPastePreview;\nexport type PasteClassNames = ComposerPasteClassNames;\n","type":"registry:ui","target":"components\/fancy\/paste-pills\/PastePills.types.ts"},{"path":"components\/fancy\/paste-pills\/PastedFilesInput.tsx","content":"import { useEffect, useRef } from \"react\";\n\nexport interface PastedFilesInputProps {\n  \/** The form field name the files are submitted under. *\/\n  name: string;\n  \/** The files to submit, in the order the message holds them. *\/\n  files: readonly File[];\n  \/** Associate with a `<form id>` elsewhere in the document, as `<input form>` does. *\/\n  form?: string;\n}\n\n\/**\n * A hidden `<input type=\"file\">` carrying the files pasted into a field, so a\n * plain `<form>` submits them with everything else.\n *\n * A pasted file is not in any form field: the clipboard hands it to the paste\n * handler and nowhere else. Held as a pill, it is attached as far as the person can\n * see, and a native submit would then post the text without it. A file input is\n * the one element a form posts files from, and its `files` can be set from a\n * `DataTransfer`, so the pills keep it in step: delete a pill and its file leaves\n * the input too.\n *\n * Hidden rather than visually hidden: `hidden` keeps it out of the tab order and\n * the accessibility tree, where the pills already represent it, and does not stop\n * a form submitting it.\n *\/\nexport function PastedFilesInput({ name, files, form }: PastedFilesInputProps) {\n  const ref = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    const el = ref.current;\n    \/\/ No DataTransfer (a server render, an old engine): nothing can be set, and\n    \/\/ the host still has the files from its own state.\n    if (!el || typeof DataTransfer === \"undefined\") return;\n\n    const transfer = new DataTransfer();\n    for (const file of files) transfer.items.add(file);\n    el.files = transfer.files;\n  }, [files]);\n\n  return (\n    <input\n      ref={ref}\n      type=\"file\"\n      name={name}\n      form={form}\n      multiple\n      hidden\n      tabIndex={-1}\n      data-react-fancy-pasted-files={name}\n    \/>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/paste-pills\/PastedFilesInput.tsx"},{"path":"components\/fancy\/paste-pills\/index.ts","content":"\/*\n * The paste-pill core, shared by `Composer`, `PromptInput` and `Input`.\n *\n * One folder rather than a hook in `hooks\/` and the rest inside `Composer`, so\n * `npx fancy-cli add` copies it ONCE, as a dependency of whichever of those you\n * add, and the copies cannot drift: there is only one.\n *\/\nexport { usePastePills } from \".\/use-paste-pills\";\nexport type {\n  HeldPaste,\n  PasteEventLike,\n  UsePastePillsOptions,\n  UsePastePillsResult,\n} from \".\/use-paste-pills\";\nexport { useInlinePaste } from \".\/use-inline-paste\";\nexport type { UseInlinePasteOptions } from \".\/use-inline-paste\";\nexport { InlinePasteEditor, HOST_ATTR } from \".\/InlinePasteEditor\";\nexport type { InlinePasteEditorHandle, PillToInsert } from \".\/InlinePasteEditor\";\nexport { InlinePill, endsOf, withSeparators } from \".\/InlinePill\";\nexport { PastedFilesInput } from \".\/PastedFilesInput\";\nexport type { PastedFilesInputProps } from \".\/PastedFilesInput\";\nexport { PILL_MARK, pasteMarker, segmentValue, markedIds, expandMarkers } from \".\/paste-markers\";\nexport type { HeldImage, ValueSegment } from \".\/paste-markers\";\nexport type {\n  ComposerPasteClassNames,\n  ComposerPastePreview,\n  PasteClassNames,\n  PastePreview,\n} from \".\/PastePills.types\";\n","type":"registry:ui","target":"components\/fancy\/paste-pills\/index.ts"},{"path":"components\/fancy\/paste-pills\/paste-markers.ts","content":"import type { HeldPaste } from \".\/use-paste-pills\";\n\n\/**\n * An image pasted into a `Composer` and held as a pill.\n *\n * `label` is stored rather than derived because the number in it is the order of\n * ARRIVAL: \"Pasted image 2\" must stay 2 after image 1 is deleted, and nothing left\n * in the list could recompute that.\n *\/\nexport interface HeldImage {\n  id: string;\n  file: File;\n  label: string;\n}\n\n\/**\n * U+FFFC OBJECT REPLACEMENT CHARACTER \u2014 Unicode's own \"an embedded object sits\n * here\". A marker is the id wrapped in a pair of them.\n *\n * Why the message carries a marker at all: with inline placement the message\n * ITSELF is the record of what is attached and where, so there is one answer to\n * \"what am I sending?\" rather than a text plus a parallel list that can disagree\n * the moment someone backspaces a pill. This is the MOIC estate's design for their\n * own composer, and the reason they withdrew from the beside-the-input layout.\n *\n * Only a marker whose id the composer actually holds is a pill; anything else is\n * left as text, so a stray U+FFFC in pasted content cannot invent an attachment.\n *\/\nexport const PILL_MARK = \"\\uFFFC\";\n\nconst MARKER = \/\\uFFFC([^\\uFFFC\\n]+)\\uFFFC\/g;\n\n\/** The marker for a held paste or image, for a host or agent writing `value` itself. *\/\nexport function pasteMarker(id: string): string {\n  return `${PILL_MARK}${id}${PILL_MARK}`;\n}\n\nexport type ValueSegment =\n  | { type: \"text\"; text: string }\n  | { type: \"pill\"; id: string };\n\n\/** Split a value into text and pills. Markers for ids not in `known` stay text. *\/\nexport function segmentValue(value: string, known: ReadonlySet<string>): ValueSegment[] {\n  const out: ValueSegment[] = [];\n  let last = 0;\n\n  for (const match of value.matchAll(MARKER)) {\n    const id = match[1];\n    if (!known.has(id)) continue;\n\n    const start = match.index ?? 0;\n    if (start > last) out.push({ type: \"text\", text: value.slice(last, start) });\n    out.push({ type: \"pill\", id });\n    last = start + match[0].length;\n  }\n\n  if (last < value.length) out.push({ type: \"text\", text: value.slice(last) });\n\n  return out;\n}\n\n\/** The id of every marker the value contains, in order, held or not. *\/\nexport function markedIds(value: string): string[] {\n  return Array.from(value.matchAll(MARKER), (m) => m[1]);\n}\n\n\/**\n * What actually gets sent: every text pill replaced by its full text, every image\n * pill by `[Pasted image N]` so the reader of the message can see where the image\n * sat. The images themselves go to `onSubmit`'s second argument.\n *\/\nexport function expandMarkers(\n  value: string,\n  pastes: readonly HeldPaste[],\n  images: readonly HeldImage[],\n): string {\n  const text = new Map(pastes.map((p) => [p.id, p.text]));\n  const image = new Map(images.map((i) => [i.id, `[${i.label}]`]));\n\n  return value.replace(MARKER, (whole, id: string) => text.get(id) ?? image.get(id) ?? whole);\n}\n","type":"registry:ui","target":"components\/fancy\/paste-pills\/paste-markers.ts"},{"path":"components\/fancy\/paste-pills\/use-inline-paste.tsx","content":"import { useCallback, useMemo, useRef, useState, type ReactNode, type RefObject } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { useControllableState } from \"..\/lib\/hooks\/use-controllable-state\";\nimport { usePastePills, type HeldPaste } from \".\/use-paste-pills\";\nimport { InlinePill } from \".\/InlinePill\";\nimport type { InlinePasteEditorHandle, PillToInsert } from \".\/InlinePasteEditor\";\nimport { expandMarkers, markedIds, type HeldImage } from \".\/paste-markers\";\nimport type { ComposerPasteClassNames, ComposerPastePreview } from \".\/PastePills.types\";\n\nexport interface UseInlinePasteOptions {\n  pasteThreshold?: number;\n  pasteImages?: boolean;\n  previewChars: number;\n  renderPastePreview?: (preview: ComposerPastePreview) => ReactNode;\n  classNames?: ComposerPasteClassNames;\n  disabled: boolean;\n  heldPastes?: HeldPaste[];\n  onHeldPastesChange?: (pastes: HeldPaste[]) => void;\n  heldImages?: HeldImage[];\n  onHeldImagesChange?: (images: HeldImage[]) => void;\n  editorRef: RefObject<InlinePasteEditorHandle | null>;\n  \/**\n   * Give a text pill a line to itself. Default `true`; a single-line field\n   * (`Input`) has no lines to give, so its pills sit in the text.\n   *\/\n  blockText?: boolean;\n}\n\n\/**\n * Everything inline paste pills need besides the editor itself: the held pastes\n * and images, deciding what a paste becomes, dropping a pill's hold when it is\n * deleted from the message, rendering the pills into the editor, and expanding\n * them on send.\n *\n * Shared by `Composer`, `PromptInput` and `Input` so they cannot disagree about\n * what a pill means. The rule that must not drift is the one `expand` and `imagesIn`\n * carry: a send with pills still closed carries everything they hold.\n *\/\nexport function useInlinePaste({\n  pasteThreshold,\n  pasteImages = false,\n  previewChars,\n  renderPastePreview,\n  classNames,\n  disabled,\n  heldPastes: controlledHeldPastes,\n  onHeldPastesChange,\n  heldImages: controlledHeldImages,\n  onHeldImagesChange,\n  editorRef,\n  blockText = true,\n}: UseInlinePasteOptions) {\n  const pills = usePastePills({\n    threshold: pasteThreshold,\n    heldPastes: controlledHeldPastes,\n    onHeldPastesChange,\n  });\n  const heldPastes = pills.heldPastes;\n  const [heldImages, setHeldImages] = useControllableState<HeldImage[]>(\n    controlledHeldImages,\n    [],\n    onHeldImagesChange,\n  );\n  const imageCount = useRef(0);\n\n  const known = useMemo(\n    () => new Set([...heldPastes.map((p) => p.id), ...heldImages.map((i) => i.id)]),\n    [heldPastes, heldImages],\n  );\n\n  const holdImage = useCallback(\n    (file: File): string => {\n      let id: string;\n      do {\n        imageCount.current += 1;\n        id = `image-${imageCount.current}`;\n      } while (heldImages.some((i) => i.id === id));\n\n      const image = { id, file, label: `Pasted image ${imageCount.current}` };\n      setHeldImages((current) => [...current, image]);\n\n      return id;\n    },\n    [heldImages, setHeldImages],\n  );\n\n  \/** What a paste becomes. `null` means \"just text\". *\/\n  const decidePaste = useCallback(\n    (data: DataTransfer | null): PillToInsert[] | null => {\n      const text = data?.getData(\"text\/plain\") ?? \"\";\n\n      \/\/ Only a PURE image paste: a clipboard with text as well usually carries a\n      \/\/ picture OF that text (copying from a document), and holding it would turn\n      \/\/ every pasted sentence into a screenshot of the sentence.\n      if (pasteImages && text === \"\") {\n        const files = Array.from(data?.files ?? []).filter((f) =>\n          f.type.startsWith(\"image\/\"),\n        );\n        if (files.length > 0) {\n          return files.map((file) => ({ id: holdImage(file), block: false }));\n        }\n      }\n\n      if (pasteThreshold && pasteThreshold > 0 && text.length >= pasteThreshold) {\n        return [{ id: pills.hold(text), block: blockText }];\n      }\n\n      return null;\n    },\n    [pasteImages, pasteThreshold, holdImage, pills, blockText],\n  );\n\n  \/*\n   * The message is the record of what is attached. A pill backspaced out of it is\n   * no longer attached, so its held entry goes too \u2014 otherwise state would say\n   * \"attached\" about something the message no longer contains.\n   *\/\n  const prune = useCallback(\n    (next: string) => {\n      const present = new Set(markedIds(next));\n      for (const paste of heldPastes) if (!present.has(paste.id)) pills.remove(paste.id);\n      if (heldImages.some((i) => !present.has(i.id))) {\n        setHeldImages((current) => current.filter((i) => present.has(i.id)));\n      }\n    },\n    [heldPastes, heldImages, pills, setHeldImages],\n  );\n\n  const [hosts, setHosts] = useState<ReadonlyMap<string, HTMLElement>>(new Map());\n  const onHostsChange = useCallback((next: ReadonlyMap<string, HTMLElement>) => {\n    setHosts((prev) => {\n      if (prev.size === next.size && [...next].every(([id, el]) => prev.get(id) === el)) {\n        return prev;\n      }\n      return next;\n    });\n  }, []);\n\n  \/** Click a text pill and its full text takes the pill's place. *\/\n  const expandPill = useCallback(\n    (id: string) => {\n      const text = pills.expand(id);\n      if (text === undefined) return;\n      editorRef.current?.replacePill(id, text);\n    },\n    [pills, editorRef],\n  );\n\n  \/** The value as it is sent: every text pill expanded, every image marked. *\/\n  const expand = useCallback(\n    (value: string) => expandMarkers(value, heldPastes, heldImages),\n    [heldPastes, heldImages],\n  );\n\n  \/** The images the value still contains, in message order. *\/\n  const imagesIn = useCallback(\n    (value: string) =>\n      markedIds(value).flatMap((id) => heldImages.filter((i) => i.id === id)),\n    [heldImages],\n  );\n\n  const clear = useCallback(() => {\n    pills.clear();\n    setHeldImages([]);\n  }, [pills, setHeldImages]);\n\n  \/** The pills, portalled into the placeholders the editor made. Render this anywhere. *\/\n  const portals = Array.from(hosts, ([id, host]) => {\n    const paste = heldPastes.find((p) => p.id === id);\n    const image = paste ? undefined : heldImages.find((i) => i.id === id);\n    const preview: ComposerPastePreview | null = paste\n      ? { kind: \"text\", paste }\n      : image\n        ? { kind: \"image\", image }\n        : null;\n\n    return preview\n      ? createPortal(\n          <InlinePill\n            preview={preview}\n            previewChars={previewChars}\n            renderPastePreview={renderPastePreview}\n            classNames={classNames}\n            disabled={disabled}\n            onExpand={expandPill}\n          \/>,\n          host,\n          id,\n        )\n      : null;\n  });\n\n  return {\n    pills,\n    heldPastes,\n    heldImages,\n    known,\n    decidePaste,\n    prune,\n    onHostsChange,\n    expand,\n    imagesIn,\n    clear,\n    portals,\n  };\n}\n","type":"registry:ui","target":"components\/fancy\/paste-pills\/use-inline-paste.tsx"},{"path":"components\/fancy\/paste-pills\/use-paste-pills.ts","content":"import { useCallback, useRef } from \"react\";\nimport { useControllableState } from \"..\/lib\/hooks\/use-controllable-state\";\n\n\/**\n * A paste held beside a composer rather than inserted into it.\n *\n * There is deliberately **no `length` field.** It would be `text.length` and\n * nothing else, and a derived value stored beside its source is a value that can\n * disagree with it \u2014 the two drift the moment anything edits one. Read\n * `paste.text.length`.\n *\/\nexport interface HeldPaste {\n  id: string;\n  text: string;\n}\n\n\/**\n * The minimum of a clipboard event this hook touches.\n *\n * Structural rather than `React.ClipboardEvent` so a host can call it from a\n * native listener, a synthetic one, or a test double. React's own event satisfies\n * it.\n *\/\nexport interface PasteEventLike {\n  clipboardData?: { getData(type: string): string } | null;\n  preventDefault(): void;\n}\n\nexport interface UsePastePillsOptions {\n  \/**\n   * Hold a paste of at least this many characters. Try 500 \u2014 about a long\n   * paragraph.\n   *\n   * **No default, on purpose.** Paste is a fundamental interaction, so holding one\n   * is something a host asks for rather than something it discovers. Omit this and\n   * `handlePaste` always returns `false` and touches nothing.\n   *\/\n  threshold?: number;\n  \/** Own the list yourself. Omit to let the hook keep it. *\/\n  heldPastes?: HeldPaste[];\n  onHeldPastesChange?: (pastes: HeldPaste[]) => void;\n}\n\nexport interface UsePastePillsResult {\n  heldPastes: HeldPaste[];\n  \/**\n   * Call this from your OWN paste handler. Returns `true` if it took the paste \u2014\n   * in which case it has already called `preventDefault()` \u2014 and `false` if it\n   * declined, having touched nothing.\n   *\n   * Composable on purpose: a host whose handler already intercepts paste for its\n   * own reasons needs to ask \"did you want this?\" rather than hand over the event.\n   *\n   *     function onPaste(e) {\n   *       if (pills.handlePaste(e)) return;\n   *       \/\/ ...your own handling\n   *     }\n   *\/\n  handlePaste: (event: PasteEventLike) => boolean;\n  \/**\n   * Hold `text` unconditionally and return the new pill's id.\n   *\n   * For a host that decides for itself what to hold and needs to know WHICH pill\n   * it made \u2014 `Composer`'s inline placement puts the pill at the caret, so it needs\n   * the id at paste time. Ignores `threshold`; the caller has already decided.\n   *\/\n  hold: (text: string) => string;\n  \/**\n   * Removes the paste and returns its text, for the host to insert into its own\n   * surface. `undefined` if there is no such id.\n   *\n   * The hook cannot insert it \u2014 it owns no DOM and does not know what a host's\n   * surface is. That is the point.\n   *\/\n  expand: (id: string) => string | undefined;\n  \/** Discard one without returning it. *\/\n  remove: (id: string) => void;\n  \/** Empty the list, e.g. after a send. *\/\n  clear: () => void;\n}\n\n\/**\n * Hold a large paste as state, so a composer can show it as a pill instead of\n * turning itself into a scrolling wall.\n *\n * ---------------------------------------------------------------------------\n * Why this is a hook and not just a feature of `Composer`\n * ---------------------------------------------------------------------------\n *\n * `Composer` shipped the behaviour in 5.31.0 and the MOIC estate, who specified it,\n * then established something I had missed: their chat input is a contenteditable\n * rendering inline `[deal:42|Acme]` badges, so they **cannot** adopt `Composer` (a\n * plain textarea) however much they want its behaviour. Their plan to delete their\n * own composer when ours landed could never have worked.\n *\n * So the logic lives here and **`Composer` is its first caller.** One\n * implementation rather than two, which matters more than the convenience: two\n * copies of a rule drift, and the copy nobody reads is the one that rots. The rule\n * that must not drift is the one in `Composer`'s own tests \u2014 a send with pills\n * still closed carries the text.\n *\n * It owns no DOM. The host renders the pills.\n *\n * ---------------------------------------------------------------------------\n * Who actually calls this (as of 5.32.0): `Composer`, and nothing else\n * ---------------------------------------------------------------------------\n *\n * The estate that specified it **withdrew before adopting it**, hours after it\n * shipped \u2014 their design moved the pill INTO the message as a `[paste:id|label]`\n * marker in the same grammar as their other object tags, which makes the message\n * itself the record of what is attached. A parallel `heldPastes` array would then be\n * a second place answering \"what am I sending?\", and the two would disagree the\n * moment someone backspaced a badge. Their reasoning was better than the spec.\n *\n * Recorded because a reader should not infer an extension point under active\n * external use. It is kept rather than withdrawn for two reasons that are about\n * cost, not optimism: it is published API on a 5.x package, so removing it needs a\n * major; and `Composer` calls it, so it is exercised by that component's tests on\n * every run and cannot rot unnoticed.\n *\n * **It gets no further surface without a real caller.** No extra options, no second\n * hook. An API with one hypothetical consumer is how a package accumulates surface\n * nobody uses.\n *\n * `hold` (5.38.0) met that bar: `Composer`'s inline placement \u2014 MOIC's\n * pill-inside-the-message design, built into the component \u2014 needs the id of the\n * pill it just made so it can put it at the caret.\n *\/\nexport function usePastePills({\n  threshold,\n  heldPastes: controlledHeldPastes,\n  onHeldPastesChange,\n}: UsePastePillsOptions = {}): UsePastePillsResult {\n  const [heldPastes, setHeldPastes] = useControllableState<HeldPaste[]>(\n    controlledHeldPastes,\n    [],\n    onHeldPastesChange,\n  );\n\n  \/*\n   * A counter rather than `crypto.randomUUID()` or an index.\n   *\n   * An index is not an id \u2014 removing the first pill renumbers the rest, and a host\n   * keying its DOM on that remounts every pill and loses hover state. A counter in\n   * a ref is stable for the life of the component and needs no platform API that a\n   * server renderer might lack.\n   *\/\n  const nextId = useRef(0);\n\n  const hold = useCallback(\n    (text: string): string => {\n      \/*\n       * Skip any id the list already holds. A controlled list can arrive seeded\n       * (restored drafts, an agent attaching a paste) before this counter has\n       * counted anything, and minting `paste-1` beside an existing `paste-1` gives\n       * two pills one id \u2014 expanding either then takes both.\n       *\/\n      let id: string;\n      do {\n        nextId.current += 1;\n        id = `paste-${nextId.current}`;\n      } while (heldPastes.some((p) => p.id === id));\n\n      setHeldPastes((current) => [...current, { id, text }]);\n\n      return id;\n    },\n    [heldPastes, setHeldPastes],\n  );\n\n  const handlePaste = useCallback(\n    (event: PasteEventLike): boolean => {\n      if (!threshold || threshold <= 0) return false;\n\n      const text = event.clipboardData?.getData(\"text\/plain\") ?? \"\";\n\n      \/\/ Below the threshold, decline WITHOUT touching the event. A host calling\n      \/\/ this from inside its own handler has to be able to carry on as if the hook\n      \/\/ were not there.\n      if (text.length < threshold) return false;\n\n      event.preventDefault();\n      hold(text);\n\n      return true;\n    },\n    [threshold, hold],\n  );\n\n  const expand = useCallback(\n    (id: string): string | undefined => {\n      const found = heldPastes.find((p) => p.id === id);\n\n      if (found === undefined) return undefined;\n\n      setHeldPastes((current) => current.filter((p) => p.id !== id));\n\n      return found.text;\n    },\n    [heldPastes, setHeldPastes],\n  );\n\n  const remove = useCallback(\n    (id: string) => setHeldPastes((current) => current.filter((p) => p.id !== id)),\n    [setHeldPastes],\n  );\n\n  const clear = useCallback(() => setHeldPastes([]), [setHeldPastes]);\n\n  return { heldPastes, handlePaste, hold, expand, remove, clear };\n}\n","type":"registry:ui","target":"components\/fancy\/paste-pills\/use-paste-pills.ts"},{"path":"components\/fancy\/lib\/utils\/cn.ts","content":"import { twMerge } from \"tailwind-merge\";\n\n\/**\n * A class value: a string, a number, a conditional map, an array of any of\n * those, or nothing. Mirrors what `clsx` accepted, because every component in\n * this package already passes these shapes.\n *\/\nexport type ClassValue =\n  | string\n  | number\n  | bigint\n  | null\n  | undefined\n  | false\n  | ClassDictionary\n  | ClassValue[];\n\ntype ClassDictionary = Record<string, unknown>;\n\n\/**\n * Flatten class values into a space-separated string.\n *\n * This was `clsx` until 2026-08-19. It is roughly twenty lines of logic, it\n * shipped as a RUNTIME dependency to every consumer of this package, and its\n * last commit was 801 days old \u2014 so it fell to the rule that third-party code\n * must be both approved and actively maintained. Owning it costs less than\n * carrying it.\n *\n * `tailwind-merge` deliberately stays: deciding which of two conflicting\n * Tailwind utilities wins requires modelling the entire utility space, which is\n * emphatically not twenty lines, and it is actively maintained.\n *\n * Falsy values are dropped \u2014 including `0` and `\"\"`, which is what `clsx` did\n * and what `tests\/cn.test.ts` pins. A truthy number is kept, because digits are\n * legal class names. `bigint` is admitted because clsx's signature did and\n * the compiler proved it reachable from two call sites here.\n *\/\nfunction flatten(value: ClassValue): string {\n  if (!value) return \"\";\n\n  if (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"bigint\") {\n    return String(value);\n  }\n\n  if (Array.isArray(value)) {\n    let out = \"\";\n    for (const item of value) {\n      const part = flatten(item);\n      if (part) out = out ? `${out} ${part}` : part;\n    }\n    return out;\n  }\n\n  let out = \"\";\n  for (const key in value) {\n    if (value[key]) out = out ? `${out} ${key}` : key;\n  }\n  return out;\n}\n\nexport function cn(...inputs: ClassValue[]): string {\n  return twMerge(flatten(inputs));\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/cn.ts"},{"path":"components\/fancy\/lib\/hooks\/use-controllable-state.ts","content":"import { useCallback, useRef, useState } from \"react\";\n\n\/**\n * One state slot that works controlled or uncontrolled.\n *\n * ---------------------------------------------------------------------------\n * Why the value is tracked in a ref (fixed 5.32.0)\n * ---------------------------------------------------------------------------\n *\n * This resolved a functional update against the RENDER CLOSURE's `value` and then\n * handed React an already-computed value:\n *\n *     const nextValue = typeof next === \"function\" ? next(value) : next;\n *     setUncontrolledValue(nextValue);\n *\n * So `setValue(fn)` twice in one batch had both calls compute from the same stale\n * `value`, and **the first update was silently lost.** React's own `setState`\n * updater form does not have that problem precisely because React resolves it\n * against the latest state; passing a pre-computed value gives that up.\n *\n * Found by a hook test that paste-held two items inside one `act()` and got one.\n * It is not an exotic case \u2014 any caller that loops, or handles two events in a\n * batch, hits it, and the symptom is a dropped update with nothing to indicate one\n * happened.\n *\n * The fix cannot be \"pass the updater to React\", because `onChange` has to be\n * called with the resulting value and an updater must stay pure \u2014 React may invoke\n * it twice (StrictMode), which would fire `onChange` twice. So the previous value\n * comes from a ref that is re-synced from the authoritative `value` on every render\n * and advanced on every set. Within a batch the ref carries intent; after a render\n * the real value wins.\n *\n * A side effect worth having: `setValue` no longer depends on `value`, so it is\n * stable for the life of the component.\n *\/\nexport function useControllableState<T>(\n  controlledValue: T | undefined,\n  defaultValue: T,\n  onChange?: (value: T) => void,\n): [T, (value: T | ((prev: T) => T)) => void] {\n  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n  const isControlled = controlledValue !== undefined;\n  const value = isControlled ? controlledValue : uncontrolledValue;\n\n  const onChangeRef = useRef(onChange);\n  onChangeRef.current = onChange;\n\n  \/\/ Re-synced every render from whichever side owns the value. A controlled parent\n  \/\/ that ignores `onChange` therefore wins on the next render rather than being\n  \/\/ overridden by what this hook optimistically recorded.\n  const valueRef = useRef(value);\n  valueRef.current = value;\n\n  const isControlledRef = useRef(isControlled);\n  isControlledRef.current = isControlled;\n\n  const setValue = useCallback((next: T | ((prev: T) => T)) => {\n    const nextValue =\n      typeof next === \"function\"\n        ? (next as (prev: T) => T)(valueRef.current)\n        : next;\n\n    \/\/ Advance the ref BEFORE notifying, so a second call in the same batch builds\n    \/\/ on this one instead of on what the last render saw.\n    valueRef.current = nextValue;\n\n    if (!isControlledRef.current) {\n      setUncontrolledValue(nextValue);\n    }\n\n    onChangeRef.current?.(nextValue);\n  }, []);\n\n  return [value, setValue];\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-controllable-state.ts"},{"path":"components\/fancy\/lib\/utils\/types.ts","content":"import type { ElementType } from \"react\";\n\nexport type Size = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n\n\/**\n * What a navigation primitive owes a router.\n *\n * Every nav surface here \u2014 `Navbar.Item`, `Sidebar.Item`, `Menu.Item`,\n * `MobileMenu.Item`, `Breadcrumbs.Item` \u2014 used to hardcode a plain `<a href>`.\n * That is a FULL PAGE LOAD in every client-routed app, and the only way around\n * it was to nest a router `<Link>` inside the item: an anchor inside an anchor,\n * which is invalid HTML and the same nested-anchor shape that already cost this\n * suite an SSR hydration bug.\n *\n * `as` is the seam {@link ButtonProps.as} already had. It is router-agnostic on\n * purpose \u2014 one prop serves Inertia, TanStack Router, React Router and Next\n * rather than a package per router:\n *\n * ```tsx\n * \/\/ href-based routers (Inertia, Next)\n * <Navbar.Item as={Link} href=\"\/dashboard\">Dashboard<\/Navbar.Item>\n *\n * \/\/ to-based routers (TanStack Router, React Router) \u2014 `to` reaches the Link\n * \/\/ through the forwarded rest props\n * <Navbar.Item as={Link} to=\"\/dashboard\">Dashboard<\/Navbar.Item>\n * ```\n *\n * Link mode engages on `href` OR `as`, because a `to`-based router never passes\n * an `href` and would otherwise silently render the non-interactive fallback.\n *\/\nexport interface NavigableProps {\n  \/** Target URL. Renders link mode with a plain `<a>` unless `as` is given. *\/\n  href?: string;\n  \/**\n   * Link-mode element override \u2014 the component to render INSTEAD of the plain\n   * `<a>`, e.g. a router's `<Link>` so navigation happens client-side. Receives\n   * `className`, the item's `data-*` handles, and every unrecognized prop, so\n   * router-specific props (`to`, `params`, `preload`, `search`) pass straight\n   * through. Ignored in button mode.\n   *\/\n  as?: ElementType;\n}\n\n\/**\n * The full Tailwind v4 named color palette \u2014 every default hue, including all\n * five gray families \u2014 plus `brand`, the host's own colour. Components that\n * accept a `color` should type it as this so the whole palette is available.\n *\n * `brand` is not a hue. It resolves through three CSS variables the host sets\n * once, rather than through a Tailwind scale:\n *\n *   `--color-brand`, `--color-brand-contrast`, `--color-brand-hover`\n *\n * Three rather than one because a generated hover assumes \"same hue, darker\",\n * and a brand palette need not work that way \u2014 navy whose hover is gold cannot\n * be derived from navy by any amount of arithmetic. The host states it.\n *\/\nexport type Color =\n  \/\/ the host's own colour, resolved from CSS variables (see styles.css)\n  | \"brand\"\n  \/\/ grays\n  | \"slate\"\n  | \"gray\"\n  | \"zinc\"\n  | \"neutral\"\n  | \"stone\"\n  \/\/ hues\n  | \"red\"\n  | \"orange\"\n  | \"amber\"\n  | \"yellow\"\n  | \"lime\"\n  | \"green\"\n  | \"emerald\"\n  | \"teal\"\n  | \"cyan\"\n  | \"sky\"\n  | \"blue\"\n  | \"indigo\"\n  | \"violet\"\n  | \"purple\"\n  | \"fuchsia\"\n  | \"pink\"\n  | \"rose\";\n\n\/** Every value of {@link Color}, for building exhaustive class maps. *\/\nexport const COLORS = [\n  \"brand\",\n  \"slate\", \"gray\", \"zinc\", \"neutral\", \"stone\",\n  \"red\", \"orange\", \"amber\", \"yellow\", \"lime\", \"green\", \"emerald\", \"teal\",\n  \"cyan\", \"sky\", \"blue\", \"indigo\", \"violet\", \"purple\", \"fuchsia\", \"pink\", \"rose\",\n] as const satisfies readonly Color[];\n\nexport type Variant = \"solid\" | \"outline\" | \"ghost\" | \"soft\";\n\n\/** Button accepts the full Tailwind v4 palette (alias of {@link Color}). *\/\nexport type ButtonColor = Color;\n\n\/**\n * @deprecated Renamed to {@link ButtonColor}. `ActionColor` remains as an alias\n * for backward compatibility and will be removed in a future major version.\n *\/\nexport type ActionColor = ButtonColor;\n\nexport type Placement =\n  | \"top\"\n  | \"bottom\"\n  | \"left\"\n  | \"right\"\n  | \"top-start\"\n  | \"top-end\"\n  | \"bottom-start\"\n  | \"bottom-end\";\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/types.ts"},{"path":"components\/fancy\/lib\/hooks\/use-floating-position.ts","content":"import { useCallback, useEffect, useState, type RefObject } from \"react\";\nimport type { Placement } from \"..\/utils\/types\";\n\ninterface FloatingPosition {\n  x: number;\n  y: number;\n  placement: Placement;\n}\n\ninterface UseFloatingPositionOptions {\n  placement?: Placement;\n  offset?: number;\n  enabled?: boolean;\n}\n\nfunction getPosition(\n  anchor: DOMRect,\n  floating: DOMRect,\n  placement: Placement,\n  offset: number,\n): FloatingPosition {\n  let x = 0;\n  let y = 0;\n  const base = placement.split(\"-\")[0] as \"top\" | \"bottom\" | \"left\" | \"right\";\n  const align = placement.split(\"-\")[1] as \"start\" | \"end\" | undefined;\n\n  switch (base) {\n    case \"top\":\n      x = anchor.left + anchor.width \/ 2 - floating.width \/ 2;\n      y = anchor.top - floating.height - offset;\n      break;\n    case \"bottom\":\n      x = anchor.left + anchor.width \/ 2 - floating.width \/ 2;\n      y = anchor.bottom + offset;\n      break;\n    case \"left\":\n      x = anchor.left - floating.width - offset;\n      y = anchor.top + anchor.height \/ 2 - floating.height \/ 2;\n      break;\n    case \"right\":\n      x = anchor.right + offset;\n      y = anchor.top + anchor.height \/ 2 - floating.height \/ 2;\n      break;\n  }\n\n  if (base === \"top\" || base === \"bottom\") {\n    if (align === \"start\") x = anchor.left;\n    else if (align === \"end\") x = anchor.right - floating.width;\n  }\n\n  if (base === \"left\" || base === \"right\") {\n    if (align === \"start\") y = anchor.top;\n    else if (align === \"end\") y = anchor.bottom - floating.height;\n  }\n\n  \/\/ Viewport clamping\n  let finalPlacement = placement;\n  const vw = window.innerWidth;\n  const vh = window.innerHeight;\n\n  if (base === \"bottom\" && y + floating.height > vh) {\n    y = anchor.top - floating.height - offset;\n    finalPlacement = placement.replace(\"bottom\", \"top\") as Placement;\n  } else if (base === \"top\" && y < 0) {\n    y = anchor.bottom + offset;\n    finalPlacement = placement.replace(\"top\", \"bottom\") as Placement;\n  }\n\n  x = Math.max(4, Math.min(x, vw - floating.width - 4));\n  y = Math.max(4, Math.min(y, vh - floating.height - 4));\n\n  return { x, y, placement: finalPlacement };\n}\n\nexport function useFloatingPosition(\n  anchorRef: RefObject<HTMLElement | null>,\n  floatingRef: RefObject<HTMLElement | null>,\n  options: UseFloatingPositionOptions = {},\n): FloatingPosition {\n  const { placement = \"bottom\", offset = 8, enabled = true } = options;\n  const [position, setPosition] = useState<FloatingPosition>({\n    x: -9999,\n    y: -9999,\n    placement,\n  });\n\n  const update = useCallback(() => {\n    const anchor = anchorRef.current;\n    const floating = floatingRef.current;\n    if (!anchor || !floating) return;\n\n    const anchorRect = anchor.getBoundingClientRect();\n    const floatingRect = floating.getBoundingClientRect();\n    const next = getPosition(anchorRect, floatingRect, placement, offset);\n    \/\/ Bail out when nothing changed so this can run after every commit\n    \/\/ (below) without causing an infinite render loop.\n    setPosition((prev) =>\n      prev.x === next.x && prev.y === next.y && prev.placement === next.placement\n        ? prev\n        : next,\n    );\n  }, [anchorRef, floatingRef, placement, offset]);\n\n  \/\/ Re-measure after every commit while open.\n  \/\/\n  \/\/ The floating element mounts a frame or two AFTER `enabled` flips \u2014 its\n  \/\/ render is gated behind a state flip (e.g. useAnimation's `mounted`), so at\n  \/\/ the moment `enabled` turns true the floating ref is still null and a single\n  \/\/ measurement no-ops, leaving the popover parked at its off-screen sentinel.\n  \/\/\n  \/\/ A passive effect with no deps runs after *every* commit \u2014 including the one\n  \/\/ that finally mounts the floating element \u2014 and, crucially, keeps running in\n  \/\/ hidden\/background tabs where `requestAnimationFrame` is paused. The bail-out\n  \/\/ in `update` makes the steady-state calls free, so this both fixes the\n  \/\/ late-mount race and survives tab-visibility throttling.\n  useEffect(() => {\n    if (enabled) update();\n  });\n\n  \/\/ \u2026but the per-commit effect ISN'T enough when the floating element mounts\n  \/\/ through a <Portal> whose own mount gate (SSR-safe; renders nothing until its\n  \/\/ useEffect runs) attaches the node WITHOUT re-rendering this hook's component.\n  \/\/ Then no commit happens here, the measurement never runs, and the popover\n  \/\/ stays parked at its off-screen sentinel until an unrelated scroll\/resize\n  \/\/ fires `update()` \u2014 the \"menu only appears after I scroll\" bug. Poll with rAF\n  \/\/ until the node is actually in the DOM, measure it, then keep it observed for\n  \/\/ size changes (async content \/ lazily-loaded icons).\n  useEffect(() => {\n    if (!enabled) return;\n    let raf = 0;\n    let ro: ResizeObserver | null = null;\n    const tick = () => {\n      const el = floatingRef.current;\n      if (el) {\n        update();\n        if (typeof ResizeObserver !== \"undefined\") {\n          ro = new ResizeObserver(() => update());\n          ro.observe(el);\n        }\n      } else {\n        raf = requestAnimationFrame(tick);\n      }\n    };\n    raf = requestAnimationFrame(tick);\n    return () => {\n      cancelAnimationFrame(raf);\n      ro?.disconnect();\n    };\n  }, [enabled, update, floatingRef]);\n\n  \/\/ Park off-screen when closed so the next open re-measures from scratch.\n  useEffect(() => {\n    if (!enabled) {\n      setPosition((prev) =>\n        prev.x === -9999 ? prev : { x: -9999, y: -9999, placement },\n      );\n    }\n  }, [enabled, placement]);\n\n  \/\/ Reposition on scroll\/resize while open.\n  useEffect(() => {\n    if (!enabled) return;\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n\n    return () => {\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [update, enabled]);\n\n  return position;\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-floating-position.ts"},{"path":"components\/fancy\/lib\/hooks\/use-outside-click.ts","content":"import { useEffect, type RefObject } from \"react\";\n\nexport function useOutsideClick(\n  ref: RefObject<HTMLElement | null>,\n  handler: (event: MouseEvent | TouchEvent) => void,\n  enabled: boolean = true,\n  ignoreRef?: RefObject<HTMLElement | null>,\n): void {\n  useEffect(() => {\n    if (!enabled) return;\n\n    const listener = (event: MouseEvent | TouchEvent) => {\n      const el = ref.current;\n      if (!el || el.contains(event.target as Node)) return;\n      \/\/ Ignore clicks on the trigger\/anchor element \u2014 the trigger's\n      \/\/ own onClick handler manages toggling.\n      if (ignoreRef?.current?.contains(event.target as Node)) return;\n      handler(event);\n    };\n\n    document.addEventListener(\"mousedown\", listener);\n    document.addEventListener(\"touchstart\", listener);\n\n    return () => {\n      document.removeEventListener(\"mousedown\", listener);\n      document.removeEventListener(\"touchstart\", listener);\n    };\n  }, [ref, handler, enabled, ignoreRef]);\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-outside-click.ts"},{"path":"components\/fancy\/lib\/hooks\/use-escape-key.ts","content":"import { createContext, useContext, useEffect, useRef } from \"react\";\n\n\/**\n * Escape belongs to the innermost open layer.\n *\n * Every `useEscapeKey(handler, enabled)` call is a LAYER while `enabled`: a\n * Dropdown's menu, a Select's listbox, a Popover, a Modal, a Drawer, a Tooltip,\n * a full-screen surface. They all join one stack, one document listener reads\n * each Escape, and only one layer gets it.\n *\n * Why not one document listener per layer, as this hook used to do: every\n * open layer then received the same Escape. A Dropdown inside a Modal closed\n * the menu AND the dialog. Inside a full-screen surface it was worse: the\n * surface's own key handler runs during the event's trip through the surface,\n * before any document listener, so it could not tell that a menu wanted the\n * key. DeckEditor exited full screen with its Shape menu still open. Fixing\n * that by ordering listeners would only last until someone registers one in a\n * different place. So the decision is made from the stack instead.\n *\n * \"Innermost\" is decided in two steps:\n *\n * 1. The most recently opened layer is the candidate. Opening a menu from a\n *    dialog is the normal order of things.\n * 2. A full-screen surface knows its own descendants: layers rendered inside\n *    it register under it through `EscapeLayerContext`, and that context flows\n *    through portals. If the candidate is a surface with open layers inside,\n *    the most recent of those wins instead. Without this step, a menu opened\n *    BEFORE an agent put its surface full screen would lose the Escape to the\n *    surface.\n *\n * An Escape that something already handled (`event.defaultPrevented`, for\n * example a text field clearing itself) is left alone.\n *\/\n\ninterface LayerEntry {\n  handler: { current: () => void };\n  \/** The surface this layer was rendered inside, if any. *\/\n  parent: EscapeScope | null;\n  \/** Set when the layer IS a surface: layers inside it point here. *\/\n  scope: EscapeScope | null;\n}\n\n\/** Identity of a full-screen surface for the layers rendered inside it. *\/\nexport type EscapeScope = object;\n\nconst stack: LayerEntry[] = [];\n\n\/** The surface (if any) that layers rendered here belong to. *\/\nexport const EscapeLayerContext = createContext<EscapeScope | null>(null);\n\nfunction innermost(): LayerEntry | undefined {\n  let candidate = stack[stack.length - 1];\n  while (candidate?.scope) {\n    const scope = candidate.scope;\n    let inner: LayerEntry | undefined;\n    for (let i = stack.length - 1; i >= 0; i--) {\n      if (stack[i].parent === scope) {\n        inner = stack[i];\n        break;\n      }\n    }\n    if (!inner) break;\n    candidate = inner;\n  }\n  return candidate;\n}\n\nfunction onDocumentKeyDown(event: KeyboardEvent) {\n  if (event.key !== \"Escape\" || event.defaultPrevented) return;\n  const target = innermost();\n  if (!target) return;\n  event.preventDefault();\n  target.handler.current();\n}\n\nfunction push(entry: LayerEntry): () => void {\n  if (stack.length === 0) document.addEventListener(\"keydown\", onDocumentKeyDown);\n  stack.push(entry);\n  return () => {\n    const i = stack.indexOf(entry);\n    if (i >= 0) stack.splice(i, 1);\n    if (stack.length === 0) document.removeEventListener(\"keydown\", onDocumentKeyDown);\n  };\n}\n\n\/**\n * Register a layer that closes on Escape while `enabled`. Pass `scope` only for\n * a surface that other layers can open inside (a full-screen surface).\n *\n * Returns `isInnermost()`, for a layer that also handles Escape in its own key\n * handler and must not act when a layer above it owns the key.\n *\n * @internal Exported for full-screen surfaces in sibling packages; ordinary\n * layers call `useEscapeKey`.\n *\/\nexport function useEscapeLayer(\n  handler: () => void,\n  enabled: boolean,\n  scope: EscapeScope | null = null,\n): { isInnermost: () => boolean } {\n  const parent = useContext(EscapeLayerContext);\n  const handlerRef = useRef(handler);\n  handlerRef.current = handler;\n  const entryRef = useRef<LayerEntry | null>(null);\n\n  \/\/ Depends on `enabled` (and identity), NOT on `handler`: callers pass inline\n  \/\/ callbacks, and re-registering on every render would move a dialog above\n  \/\/ the menu it had just opened.\n  useEffect(() => {\n    if (!enabled) return;\n    const entry: LayerEntry = { handler: handlerRef, parent, scope };\n    entryRef.current = entry;\n    const pop = push(entry);\n    return () => {\n      pop();\n      if (entryRef.current === entry) entryRef.current = null;\n    };\n  }, [enabled, parent, scope]);\n\n  const isInnermostRef = useRef(() => entryRef.current !== null && innermost() === entryRef.current);\n  return { isInnermost: isInnermostRef.current };\n}\n\nexport function useEscapeKey(handler: () => void, enabled: boolean = true): void {\n  useEscapeLayer(handler, enabled);\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-escape-key.ts"}]}