{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "composer",
  "type": "registry:ui",
  "title": "Composer",
  "description": "Where the person writes to an agent: a box on surface-1 with the Input's edge, a text field that grows a line at a time to eight rows, file chips, a toolbar for your controls and a round Send that turns into Stop while the agent answers. Send is the view's one accent fill. Enter sends, Shift+Enter starts a line, Esc stops, and Enter never sends mid-composition. Attach, paste and drop add files, suggestions fill the field, and a count shows near maxLength. FileChip is exported for a message that was already sent.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/composer.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/composer 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Composer: where the person writes to an agent (components.md, \"Composer\"). A text field that grows, file\n * chips, a toolbar and Send. FileChip is the chip on its own, for a message that was already sent.\n *\n * - Box (Press, the instrument): a plate (--litt-block, --litt-plate-lift) at the surface radius with a deep well cut into it\n *   for the text: the Input's cut at its deepest (--litt-slot-shade, --litt-field-shade over the --litt-field floor), its corners the\n *   plate's less 8px. Inside the well, top to bottom: the file chips (raised blocks resting in it) and the text area.\n *   Under the well, a rail of sm blocks on the plate, padded --litt-space-2: Attach, the caller's tools, the count, and\n *   Send, the one lit block. While the text area has focus the well takes --litt-focus-ring, as the Input's. The edges fade\n *   on the instant tier and nothing else about the box moves.\n * - Flat: one grey pill (--litt-field is grey) with no line, shade or plate, its corners half a one-line control, that grows\n *   with the text. Send is the round ink key at the end of the text's last line (grey while empty, the Button's\n *   disabled look), a removable chip a white bubble, and Attach and the tools grey words on a line under the text.\n *   Focus is the ink ring.\n * - Device: a prompt line (\">\") on LCD glass in the mono (--litt-field tokens) set in a --litt-tray bezel, the files lit cells,\n *   the tools a row of keys under the glass and Send the one key in the accent (bodies/composer.device.tsx).\n * - Text: the body size (16px on coarse pointers, so iOS never zooms) in --litt-fg, the placeholder in --litt-fg-subtle,\n *   side padding as the Input's md. It starts one line tall and grows a line at a time up to `maxRows` (8), then\n *   scrolls inside. The height is set in a layout effect, so it snaps with the text and is never animated. A\n *   visually hidden <label> names it.\n * - Keys: Enter sends and Shift+Enter starts a new line. Enter while an input method is composing (isComposing,\n *   or the keyCode 229 Safari reports for the Enter that confirms it) never sends. On a touch screen, whose soft\n *   keyboard has no Shift, Return is a new line and Send sends, unless `enterSends` says otherwise. With `enterSends={false}`,\n *   Cmd+Enter or Ctrl+Enter sends and Enter is a new line. Esc stops a streaming answer while the field has focus.\n *   While streaming, Enter sends nothing: the person can draft the next message, and Send is Stop.\n * - Send: one icon-only Button at sm (a round key in Flat, a block in Press and Device) (44px on a coarse pointer or a compact screen), named \"Send\", primary,\n *   with an ArrowUp icon: the view's one accent fill (R6). It is disabled while the trimmed text is empty or while\n *   `sendDisabled` or `disabled` is set. While `streaming` the same button becomes Stop: secondary, a filled\n *   square, named \"Stop\". It is one element, so the swap is at once and focus stays on it. After a send or a stop\n *   focus goes back to the field, and if the answer ends while Stop has focus it goes to the field too, so it\n *   never falls to the page.\n * - Files: chips above the text (FileChip), added by `files`. The Attach button, a paste and a drop all call\n *   `onFiles` with the files, and the caller owns the list. A hidden file input sits behind the Attach button,\n *   which shows only when `onFiles` is set. A paste attaches only when the clipboard holds files and no text of\n *   its own, so copying cells from a sheet still pastes text. Files dragged over the box turn its line --litt-focus and\n *   show \"Drop to attach\" in place of the text, on the instant tier. A chip you can remove is a raised block (R3:\n *   --litt-block, --litt-block-lift), a file you can lift back out of the box; with no remove button (a sent message's file,\n *   or a disabled composer) it lies flat, a plain label. A chip enters on the responsive tier\n *   (opacity and a 0.98 scale) and leaves on the exit token, as a token in the TokenInput does. Removing one\n *   moves focus to the next chip, or to the field.\n * - Suggestions: up to four prompts above the box as secondary sm Buttons. A click puts the text in the field and\n *   focuses it with the caret at the end. It never sends (decision 8). The caller drops them after the first turn.\n * - Hint and limit: `hint` is a caption line under the box in --litt-fg-subtle, linked to the field. With `maxLength`\n *   the field stops at the limit, and a tabular count shows in the toolbar once 80% is used, in --litt-danger at the\n *   limit. A polite message says it once at 80% and once at the limit, never on every key.\n * - Disabled lies flat: --litt-surface-2 with the plain --litt-line hairline and no shade, the text --litt-fg-disabled. Say the\n *   reason in the hint.\n * - Compact screens: the toolbar's buttons grow to 44px, and a bottom safe-area inset sits under the composer. The\n *   caller keeps it above the on-screen keyboard (useVisibleScreen from @/motion sizes the chat frame).\n * - Motion: only instant color and edge fades, and the chips' responsive entrance and exit. Send and Stop swap at\n *   once.\n * - Bodies (proposals/worlds.md): this file resolves the state once into ComposerBodyProps and FileChipBodyProps and\n *   renders the body the material picks (byWorld). Press, here, is the cut box above. Flat and Device live in\n *   bodies/composer.flat.tsx and bodies/composer.device.tsx and draw the same contract their own way.\n */\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ClipboardEvent,\n  type DragEvent,\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n  type Ref,\n  type ComponentPropsWithRef,\n  type CSSProperties,\n  type TextareaHTMLAttributes,\n} from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Button } from \"./button\";\nimport { ArrowUpIcon, FileIcon, PaperclipIcon, StopIcon, XIcon } from \"./icons\";\nimport { byWorld, useDevice, useExit, useMaterial, useTransition } from \"../../lib/litt/motion\";\nimport { ComposerFlatBody, FileChipFlatBody } from \"./bodies/composer.flat\";\nimport { ComposerDeviceBody, FileChipDeviceBody } from \"./bodies/composer.device\";\n\n/** A file shown as a chip above the text. The caller owns the list: add it when a file is picked, update its progress, remove it in onRemoveFile. */\nexport type ComposerFile = {\n  /** A stable id: the chip's key, and how a removal says which file it means. */\n  id: string;\n  /** The file name. A long name is cut at the end with an ellipsis. */\n  name: string;\n  /** The size: a number is bytes and reads \"2.4 MB\", a string is shown as it is. */\n  size?: number | string;\n  /** Upload progress from 0 to 100. While it is below 100 the chip shows the percent in place of the size. */\n  progress?: number;\n};\n\nconst MAX_SUGGESTIONS = 4;\nconst NEAR = 0.8; // the share of maxLength at which the count appears\n\n/** 1234567 to \"1.2 MB\". A string is the caller's own wording and passes through. */\nfunction formatSize(size: number | string): string {\n  if (typeof size === \"string\") return size;\n  if (size < 1024) return `${size} B`;\n  if (size < 1024 * 1024) return `${size < 10 * 1024 ? (size / 1024).toFixed(1) : Math.round(size / 1024)} KB`;\n  return `${(size / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/** 2000 to \"2,000\", the same on the server and in the browser. */\nconst group = (n: number) => String(n).replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n\n/* ------------------------------------------------------------------ FileChip */\n\nexport type FileChipProps = {\n  /** The file name. A long name is cut at the end with an ellipsis. */\n  name: string;\n  /** The size: a number is bytes and reads \"2.4 MB\", a string is shown as it is. */\n  size?: number | string;\n  /** Upload progress from 0 to 100. While it is below 100 the chip shows the percent in place of the size. */\n  progress?: number;\n  /** Called when the remove button is pressed, with the click. Leave it out for a file that was already sent and the chip has no button. */\n  onRemove?: (event: MouseEvent<HTMLButtonElement>) => void;\n  /** Extra classes for the chip. */\n  className?: string;\n};\n\n/** What a file chip body gets from the contract: the resolved wording and state. A body adds its look and its data-body. */\nexport type FileChipBodyProps = {\n  name: string;\n  /** The formatted size, or null when there is none to show. */\n  sizeText: string | null;\n  /** True while the upload is below 100. */\n  uploading: boolean;\n  /** The rounded percent, 0 to 100. */\n  percent: number;\n  /** The remove handler; undefined for a file that was already sent, which has no button. */\n  onRemove: ((event: MouseEvent<HTMLButtonElement>) => void) | undefined;\n  /** True on a coarse pointer: the remove button's target grows to 44px. */\n  coarse: boolean;\n  className: string;\n};\n\nconst REMOVE =\n  \"relative grid size-6 shrink-0 cursor-pointer place-items-center rounded-litt-control text-litt-fg-subtle \" +\n  \"transition-[color] duration-(--litt-dur-instant) ease-litt hover:text-litt-fg \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus \" +\n  /* An invisible 44px target around the 24px button on a finger. */\n  \"pointer-coarse:before:absolute pointer-coarse:before:-inset-2.5\";\n\n/** Press, the chip: a raised block while it can be removed, a plain label when it cannot. */\nfunction ChipPressBody({ name, sizeText, uploading, percent, onRemove, coarse, className }: FileChipBodyProps) {\n  return (\n    <span\n      data-body=\"press\"\n      className={[\n        \"inline-flex h-8 max-w-full min-w-0 items-center gap-2 rounded-litt-control pl-2 font-normal text-litt-fg\",\n        onRemove ? \"bg-litt-block pr-1 shadow-(--litt-block-lift) forced-colors:border\" : \"bg-litt-surface-1 pr-2 shadow-[inset_0_0_0_1px_var(--litt-line),var(--litt-highlight)]\",\n        className,\n      ].join(\" \")}\n    >\n      <FileIcon className=\"shrink-0 text-litt-fg-muted\" />\n      <span className=\"min-w-0 truncate text-litt-caption font-normal text-litt-fg\">{name}</span>\n      {uploading ? (\n        <>\n          <span className=\"sr-only\">, uploading {percent} percent</span>\n          <span aria-hidden=\"true\" className=\"min-w-[4ch] shrink-0 text-right text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n            {percent}%\n          </span>\n        </>\n      ) : (\n        sizeText !== null && (\n          <>\n            <span className=\"sr-only\">, </span>\n            <span className=\"shrink-0 text-litt-caption tabular-nums font-normal text-litt-fg-subtle\">{sizeText}</span>\n          </>\n        )\n      )}\n      {onRemove && (\n        <button\n          type=\"button\"\n          aria-label={`Remove ${name}`}\n          className={REMOVE + (coarse ? \" before:absolute before:-inset-2.5\" : \"\")}\n          onClick={onRemove}\n        >\n          <XIcon />\n        </button>\n      )}\n    </span>\n  );\n}\n\n/**\n * A file as a chip: an icon, the name, the size or the upload percent, and a remove button named\n * \"Remove brief.pdf\". With onRemove it is a raised block, a file you can lift out of the composer. With no\n * onRemove it lies flat, a plain label for the files of a message already sent.\n */\nexport function FileChip({ name, size, progress, onRemove, className = \"\" }: FileChipProps) {\n  const { coarse } = useDevice();\n  const Body = byWorld(useMaterial(), { press: ChipPressBody, flat: FileChipFlatBody, device: FileChipDeviceBody });\n  const percent = Math.round(Math.min(100, Math.max(0, progress ?? 100)));\n  return (\n    <Body\n      name={name}\n      sizeText={size !== undefined ? formatSize(size) : null}\n      uploading={progress !== undefined && progress < 100}\n      percent={percent}\n      onRemove={onRemove}\n      coarse={coarse}\n      className={className}\n    />\n  );\n}\n\n/* ------------------------------------------------------------------ Composer */\n\nexport type ComposerProps = {\n  /** The text, when you control it. Pair it with onValueChange. */\n  value?: string;\n  /** The text it starts with when you do not control it. */\n  defaultValue?: string;\n  /** Called with the new text on every change, and with an empty string after a send so a controlled field clears. */\n  onValueChange?: (value: string) => void;\n  /** Called with the trimmed text when the person presses Send or Enter. The field then clears. Not called while streaming or when Send is disabled. */\n  onSend?: (text: string) => void;\n  /** True while the agent answers. Send becomes Stop, Esc stops, and Enter sends nothing. */\n  streaming?: boolean;\n  /** Called when Stop is pressed, or Esc is pressed in the field, while streaming. */\n  onStop?: () => void;\n  /** Disables Send while the field stays usable, such as while the agent waits on an approval. Say why in hint. */\n  sendDisabled?: boolean;\n  /** Shown in the empty field in the subtle color, such as \"Message Litt\". It is not the label. */\n  placeholder?: string;\n  /** The field's name for screen readers. It is visually hidden, because the placeholder is what people see. */\n  label?: string;\n  /** Controls at the start of the toolbar after Attach, such as a model Select at sm. Keep them sm: Button and Select grow to 44px on a finger themselves. */\n  tools?: ReactNode;\n  /** The files to show as chips above the text. The caller owns the list. */\n  files?: ComposerFile[];\n  /** Called with the file whose remove button was pressed. Leave it out and the chips have no remove button. */\n  onRemoveFile?: (file: ComposerFile) => void;\n  /** Called with the files from the Attach picker, a paste and a drop. Passing it turns files on: the Attach button shows and the box takes drops. */\n  onFiles?: (files: File[]) => void;\n  /** Which files the picker offers, as the input's accept attribute, such as \".pdf,image/*\". */\n  accept?: string;\n  /** Up to four prompts shown above the box for an empty chat. A click fills the field and focuses it, and never sends. Drop them after the first turn. */\n  suggestions?: string[];\n  /** A line under the box in the subtle color, such as \"Litt can be wrong. Check what matters.\" Also the place to say why the composer is disabled. */\n  hint?: ReactNode;\n  /** The most characters the field takes. The count shows once 80% is used, and turns to the danger color at the limit. */\n  maxLength?: number;\n  /** How many lines it grows to before it scrolls inside. */\n  maxRows?: number;\n  /** True: Enter sends and Shift+Enter is a new line. False: Cmd or Ctrl+Enter sends and Enter is a new line, for long writing. Left out, it follows the pointer: Enter sends with a mouse or a trackpad, and on a touch screen, whose soft keyboard has no Shift, Return is a new line and Send sends. */\n  enterSends?: boolean;\n  /** Makes the whole composer unavailable: muted, skipped by Tab. Say why in hint. */\n  disabled?: boolean;\n  /** Extra classes for the composer. */\n  className?: string;\n  /** The underlying <textarea>, for focus or a form library. */\n  ref?: Ref<HTMLTextAreaElement>;\n};\n\n/** What a body gets from the contract: everything resolved, and the handlers. A body adds its look, its layout and its data-body, and nothing about what the composer does. */\nexport type ComposerBodyProps = {\n  /** The caller's className, for the body's root. */\n  className: string;\n  disabled: boolean;\n  /** True while files are dragged over the box. */\n  dragging: boolean;\n  /** A coarse pointer. */\n  coarse: boolean;\n  /** A coarse pointer or a compact screen: the toolbar's buttons grow to 44px. */\n  big: boolean;\n  /** The suggestion buttons above the box, or null. */\n  suggestions: ReactNode;\n  /** The drag handlers for the box. */\n  drop: Pick<ComponentPropsWithRef<\"div\">, \"onDragEnter\" | \"onDragOver\" | \"onDragLeave\" | \"onDrop\">;\n  /** The chips' motion items, for the body to set in its list. */\n  chips: ReactNode;\n  /** The visually hidden label's text and the field's id. */\n  label: string;\n  areaId: string;\n  /** Everything the text area needs, ref and handlers included, but not its look. */\n  area: TextareaHTMLAttributes<HTMLTextAreaElement> & { ref: (el: HTMLTextAreaElement | null) => void };\n  /** The Attach button's pieces, or null when onFiles is not set: the hidden file input and what the button does. */\n  attach: { picker: ReactNode; onClick: () => void } | null;\n  /** The caller's toolbar controls. */\n  tools: ReactNode;\n  /** The character count once 80% is used, or null. */\n  count: { id: string; used: string; max: string; full: boolean } | null;\n  /** The Send and Stop button: one element, so the swap is at once and focus stays on it. */\n  send: {\n    ref: Ref<HTMLButtonElement>;\n    streaming: boolean;\n    disabled: boolean;\n    onFocus: () => void;\n    onBlur: () => void;\n    onClick: () => void;\n  };\n  /** The hint line under the box, or null. */\n  hint: { id: string; content: ReactNode } | null;\n  /** The two polite live regions, for the body to set last. */\n  status: ReactNode;\n};\n\n/* Press, the instrument: the box is a plate (--litt-block, --litt-plate-lift) that stands on the page, and the text field is a\n   deep well cut into it: the Input's cut at its deepest, the wall shaded (--litt-slot-shade) and its floor deeper\n   (--litt-field-shade), the lit lower wall the plate's own light. The tools are a rail of blocks on the plate under the\n   well. The ring follows the text area and sits on the well. */\nconst PLATE =\n  \"group/box relative flex flex-col gap-2 rounded-litt-surface bg-litt-block p-2 font-litt-field font-normal text-litt-field-ink shadow-(--litt-plate-lift) \" +\n  \"data-[disabled]:bg-litt-surface-2 data-[disabled]:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"forced-colors:border\";\n\nconst WELL =\n  \"relative flex flex-col overflow-hidden rounded-[calc(var(--litt-radius-surface)-8px)] bg-litt-field \" +\n  \"shadow-[var(--litt-slot-shade),var(--litt-field-shade)] transition-[box-shadow] duration-(--litt-dur-instant) ease-litt \" +\n  \"has-[textarea:focus-visible]:shadow-[var(--litt-slot-shade),var(--litt-field-shade),var(--litt-focus-ring)] \" +\n  \"group-data-[dragging]/box:shadow-[inset_0_0_0_1px_var(--litt-focus),var(--litt-slot-shade),var(--litt-field-shade)] \" +\n  \"group-data-[disabled]/box:bg-transparent group-data-[disabled]/box:shadow-none \" +\n  \"forced-colors:border forced-colors:has-[textarea:focus-visible]:outline-2 forced-colors:has-[textarea:focus-visible]:outline-solid forced-colors:has-[textarea:focus-visible]:outline-offset-2\";\n\nconst AREA =\n  \"block w-full min-w-0 resize-none overflow-y-hidden bg-transparent px-litt-control-pad-md py-3 font-normal text-litt-body text-litt-field-ink \" +\n  \"outline-none placeholder:text-litt-field-ink-subtle pointer-coarse:text-(length:--litt-control-text-lg) \" +\n  \"disabled:cursor-not-allowed disabled:text-litt-fg-disabled disabled:placeholder:text-litt-fg-disabled\";\n\n/* Shown over the text while files are dragged over the box: the same place and size as the placeholder. */\nconst DROP =\n  \"pointer-events-none absolute inset-0 bg-litt-field font-litt-field px-litt-control-pad-md py-3 text-litt-body text-litt-field-ink-subtle \" +\n  \"pointer-coarse:text-(length:--litt-control-text-lg) opacity-0 transition-opacity duration-(--litt-dur-instant) ease-litt \" +\n  \"group-data-[dragging]/box:opacity-100\";\n\n/* The rail's two end blocks sit 8px in from the plate, so their corners are the plate's less 8px. A style, because\n   the button's own rounded-litt-control class would otherwise win. */\nconst END: CSSProperties = { borderRadius: \"calc(var(--litt-radius-surface) - 8px)\" };\n\n/** Press's body: a plate with a deep well cut into it, and a rail of blocks under the well. Send is the one lit block. */\nfunction PressBody({ className, disabled, dragging, big, suggestions, drop, chips, label, areaId, area, attach, tools, count, send, hint, status, coarse }: ComposerBodyProps) {\n  const grow = \"pointer-coarse:h-litt-control-md max-sm:h-litt-control-md\";\n  return (\n    <div data-body=\"press\" className={[\"flex min-w-0 flex-col gap-2 max-sm:pb-[env(safe-area-inset-bottom)]\", className].join(\" \")}>\n      {suggestions}\n\n      <div data-dragging={dragging ? \"\" : undefined} data-disabled={disabled ? \"\" : undefined} className={PLATE} {...drop}>\n        <div className={WELL}>\n          <ul role=\"list\" aria-label=\"Attached files\" className={\"hidden flex-wrap has-[li]:flex has-[li]:px-2 has-[li]:pt-2 \" + (coarse ? \"gap-3\" : \"gap-2\")}>\n            <AnimatePresence initial={false}>{chips}</AnimatePresence>\n          </ul>\n\n          <div className=\"relative\">\n            <label htmlFor={areaId} className=\"sr-only\">\n              {label}\n            </label>\n            <textarea id={areaId} rows={1} className={AREA} {...area} />\n            <span aria-hidden=\"true\" className={DROP}>\n              Drop to attach\n            </span>\n          </div>\n        </div>\n\n        {/* Too narrow for the whole rail, Send wraps to its own row at the right rather than running into the tools. */}\n        <div className=\"flex flex-wrap items-center gap-2\">\n          {attach && (\n            <>\n              {attach.picker}\n              <Button variant=\"secondary\" size={big ? \"md\" : \"sm\"} icon={<PaperclipIcon />} aria-label=\"Attach files\" disabled={disabled} className={grow} style={END} onClick={attach.onClick} />\n            </>\n          )}\n          {tools && <div className=\"flex min-w-0 items-center gap-2\">{tools}</div>}\n          <div className=\"min-w-0 flex-1\" />\n          {count && (\n            <span\n              id={count.id}\n              className={\n                \"shrink-0 px-1 text-litt-caption font-normal tabular-nums transition-colors duration-(--litt-dur-instant) ease-litt \" +\n                (count.full ? \"text-litt-danger\" : \"text-litt-fg-muted\")\n              }\n            >\n              <span className=\"sr-only\">Characters used: </span>\n              {count.used} / {count.max}\n            </span>\n          )}\n          <Button\n            ref={send.ref}\n            variant={send.streaming ? \"secondary\" : \"primary\"}\n            size={big ? \"md\" : \"sm\"}\n            icon={send.streaming ? <StopIcon /> : <ArrowUpIcon />}\n            aria-label={send.streaming ? \"Stop\" : \"Send\"}\n            disabled={send.disabled}\n            className={grow + \" ml-auto\"}\n            style={END}\n            onFocus={send.onFocus}\n            onBlur={send.onBlur}\n            onClick={send.onClick}\n          />\n        </div>\n      </div>\n\n      {hint && (\n        <p id={hint.id} className=\"px-litt-control-pad-md text-litt-caption font-normal text-litt-fg-subtle\">\n          {hint.content}\n        </p>\n      )}\n\n      {status}\n    </div>\n  );\n}\n\n/** A file in the clipboard is pasted as an attachment only when the clipboard holds no text of its own (a copied file's text is just its name). */\nfunction pastedFiles(data: DataTransfer): File[] {\n  const files = Array.from(data.files);\n  if (files.length === 0) return [];\n  const names = new Set(files.map((f) => f.name));\n  const text = data.getData(\"text/plain\").trim();\n  return text === \"\" || text.split(/\\r?\\n/).every((line) => names.has(line.trim())) ? files : [];\n}\n\n/**\n * The composer: a growing text field, file chips, a toolbar for your controls and a round Send that becomes\n * Stop while the agent answers. Hold the text yourself with value and onValueChange, or let it keep its own, and\n * call your agent in onSend. Enter sends, Shift+Enter starts a new line, and Esc stops an answer.\n */\nexport function Composer({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onSend,\n  streaming = false,\n  onStop,\n  sendDisabled = false,\n  placeholder = \"Message\",\n  label = \"Message\",\n  tools,\n  files = [],\n  onRemoveFile,\n  onFiles,\n  accept,\n  suggestions,\n  hint,\n  maxLength,\n  maxRows = 8,\n  enterSends: enterSendsProp,\n  disabled = false,\n  className = \"\",\n  ref,\n}: ComposerProps) {\n  const uid = useId();\n  const areaId = `${uid}-text`;\n  const hintId = `${uid}-hint`;\n  const countId = `${uid}-count`;\n\n  const [inner, setInner] = useState(defaultValue ?? \"\");\n  const value = valueProp ?? inner;\n  const [dragging, setDragging] = useState(false);\n  const [said, setSaid] = useState(\"\");\n\n  const { compact, coarse } = useDevice();\n  const big = compact || coarse;\n  const enterSends = enterSendsProp ?? !coarse;\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n\n  const area = useRef<HTMLTextAreaElement | null>(null);\n  const setArea = useCallback(\n    (el: HTMLTextAreaElement | null) => {\n      area.current = el;\n      if (typeof ref === \"function\") ref(el);\n      else if (ref) ref.current = el;\n    },\n    [ref],\n  );\n  const picker = useRef<HTMLInputElement>(null);\n  const action = useRef<HTMLButtonElement>(null);\n  const actionFocused = useRef(false);\n  const dragDepth = useRef(0);\n  const chips = useRef(new Map<string, HTMLLIElement>());\n  const after = useRef<string | \"field\" | null>(null);\n  const caretAtEnd = useRef(false);\n\n  function setValue(next: string) {\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n  }\n\n  /* The height follows the text a line at a time. It snaps: no transition is ever set on it. */\n  const fit = useCallback(() => {\n    const el = area.current;\n    if (!el) return;\n    const top = el.scrollTop;\n    el.style.height = \"auto\";\n    const full = el.scrollHeight;\n    if (full === 0) return; // not laid out (hidden): nothing to measure\n    const cs = getComputedStyle(el);\n    const font = parseFloat(cs.fontSize) || 15;\n    const line = parseFloat(cs.lineHeight) || font * 1.55;\n    const limit = line * maxRows + (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);\n    const over = full > limit + 0.5;\n    el.style.height = `${over ? limit : full}px`;\n    el.style.overflowY = over ? \"auto\" : \"hidden\";\n    // Typing at the end keeps the last line, and the air under it, in view. Anywhere else the scroll stays where it was.\n    if (over) el.scrollTop = document.activeElement === el && el.selectionStart === el.value.length ? el.scrollHeight : top;\n  }, [maxRows]);\n  useLayoutEffect(fit, [fit, value, coarse]);\n  useEffect(() => {\n    const el = area.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    let width = el.clientWidth;\n    const watch = new ResizeObserver(() => {\n      if (el.clientWidth === width) return; // our own height changes land here too\n      width = el.clientWidth;\n      fit();\n    });\n    watch.observe(el);\n    return () => watch.disconnect();\n  }, [fit]);\n\n  /* A suggestion puts the caret after its text, once the field holds it. */\n  useLayoutEffect(() => {\n    const el = area.current;\n    if (!caretAtEnd.current || !el || el.value !== value) return;\n    caretAtEnd.current = false;\n    el.setSelectionRange(value.length, value.length);\n  }, [value]);\n\n  /* Focus lands where a removed chip was, once the list has re-rendered. */\n  useEffect(() => {\n    const target = after.current;\n    if (!target) return;\n    after.current = null;\n    if (target !== \"field\") {\n      const next = chips.current.get(target)?.querySelector(\"button\");\n      if (next) return next.focus();\n    }\n    area.current?.focus();\n  });\n\n  /* The button swaps between Send and Stop in place. If the answer ends while it has focus and it turns disabled,\n     focus would fall to the page, so it goes to the field instead. */\n  useEffect(() => {\n    if (!actionFocused.current) return;\n    const at = document.activeElement;\n    const lost = !at || at === document.body || (at === action.current && action.current?.disabled);\n    if (lost) area.current?.focus();\n    actionFocused.current = false;\n  }, [streaming]);\n\n  /* What was added or finished is said once. A removal is said where it happens, so a send that clears the list is not announced as one. */\n  const seen = useRef<Map<string, { name: string; uploading: boolean }> | null>(null);\n  useEffect(() => {\n    const now = new Map(files.map((f) => [f.id, { name: f.name, uploading: f.progress !== undefined && f.progress < 100 }]));\n    const before = seen.current;\n    seen.current = now;\n    if (!before) return;\n    const added = [...now].filter(([id]) => !before.has(id)).map(([, f]) => f.name);\n    const done = [...now].filter(([id, f]) => before.get(id)?.uploading && !f.uploading).map(([, f]) => f.name);\n    const spoken: string[] = [];\n    if (added.length) spoken.push(`Attached ${added.join(\", \")}`);\n    if (done.length) spoken.push(`Uploaded ${done.join(\", \")}`);\n    if (spoken.length) setSaid(spoken.join(\". \"));\n  }, [files]);\n\n  const trimmed = value.trim();\n  const canSend = !disabled && !sendDisabled && !streaming && trimmed !== \"\";\n\n  function send() {\n    if (!canSend) return;\n    onSend?.(trimmed);\n    setValue(\"\");\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {\n    // An input method confirms its text with Enter. Safari reports that Enter after compositionend, as keyCode 229.\n    if (e.nativeEvent.isComposing || e.keyCode === 229) return;\n    if (e.key === \"Escape\" && streaming) {\n      e.preventDefault();\n      onStop?.();\n    } else if (e.key === \"Enter\" && !e.shiftKey && (enterSends || e.metaKey || e.ctrlKey)) {\n      e.preventDefault();\n      send();\n    }\n  }\n\n  function onPaste(e: ClipboardEvent<HTMLTextAreaElement>) {\n    if (!onFiles || disabled) return;\n    const pasted = pastedFiles(e.clipboardData);\n    if (pasted.length === 0) return;\n    e.preventDefault();\n    onFiles(pasted);\n  }\n\n  const droppable = (e: DragEvent) => !!onFiles && !disabled && Array.from(e.dataTransfer.types).includes(\"Files\");\n  function onDragEnter(e: DragEvent) {\n    if (!droppable(e)) return;\n    e.preventDefault();\n    dragDepth.current += 1;\n    setDragging(true);\n  }\n  function onDragOver(e: DragEvent) {\n    if (!droppable(e)) return;\n    e.preventDefault(); // without this the browser refuses the drop\n    e.dataTransfer.dropEffect = \"copy\";\n  }\n  function onDragLeave(e: DragEvent) {\n    if (!droppable(e)) return;\n    dragDepth.current = Math.max(0, dragDepth.current - 1);\n    if (dragDepth.current === 0) setDragging(false);\n  }\n  function onDrop(e: DragEvent) {\n    if (!droppable(e)) return;\n    e.preventDefault();\n    dragDepth.current = 0;\n    setDragging(false);\n    const dropped = Array.from(e.dataTransfer.files);\n    if (dropped.length) onFiles?.(dropped);\n  }\n\n  /** `how` is the press that asked: after a tap focus is left alone, so the keyboard does not open. */\n  function removeFile(file: ComposerFile, how: \"key\" | \"pointer\") {\n    const at = files.findIndex((f) => f.id === file.id);\n    const next = files[at + 1] ?? files[at - 1];\n    setSaid(`Removed ${file.name}`);\n    onRemoveFile?.(file);\n    if (how === \"key\" || !coarse) after.current = next ? next.id : \"field\";\n  }\n\n  const near = maxLength !== undefined && value.length >= maxLength * NEAR;\n  const full = maxLength !== undefined && value.length >= maxLength;\n  const limitSaid =\n    maxLength === undefined || !near\n      ? \"\"\n      : full\n        ? `Limit of ${group(maxLength)} characters reached`\n        : `Close to the limit of ${group(maxLength)} characters`;\n\n  const shown = (suggestions ?? []).slice(0, MAX_SUGGESTIONS);\n  const describedBy = [hint ? hintId : \"\", near ? countId : \"\"].filter(Boolean).join(\" \") || undefined;\n  const Body = byWorld(useMaterial(), { press: PressBody, flat: ComposerFlatBody, device: ComposerDeviceBody });\n\n  return (\n    <Body\n      className={className}\n      disabled={disabled}\n      dragging={dragging}\n      coarse={coarse}\n      big={big}\n      suggestions={\n        shown.length > 0 && (\n          <div role=\"group\" aria-label=\"Suggestions\" className=\"flex flex-wrap gap-2 pb-1\">\n            {shown.map((text) => (\n              <Button\n                key={text}\n                size={coarse ? \"md\" : \"sm\"}\n                disabled={disabled}\n                className=\"max-w-full pointer-coarse:h-litt-control-md [&>span]:min-w-0 [&>span>span]:truncate [&>span>span]:py-[0.25em]\"\n                onClick={() => {\n                  caretAtEnd.current = true;\n                  setValue(text);\n                  area.current?.focus();\n                }}\n              >\n                {text}\n              </Button>\n            ))}\n          </div>\n        )\n      }\n      drop={{ onDragEnter, onDragOver, onDragLeave, onDrop }}\n      chips={files.map((file) => (\n        <motion.li\n          key={file.id}\n          layout=\"position\"\n          ref={(el: HTMLLIElement | null) => {\n            if (el) chips.current.set(file.id, el);\n            else chips.current.delete(file.id);\n          }}\n          className=\"flex max-w-full min-w-0\"\n          initial={{ opacity: 0, scale: 0.98 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={{ opacity: 0, transition: exit }}\n          transition={enter}\n        >\n          <FileChip\n            name={file.name}\n            size={file.size}\n            progress={file.progress}\n            onRemove={onRemoveFile && !disabled ? (e) => removeFile(file, e.detail === 0 ? \"key\" : \"pointer\") : undefined}\n          />\n        </motion.li>\n      ))}\n      label={label}\n      areaId={areaId}\n      area={{\n        ref: setArea,\n        value,\n        placeholder,\n        disabled,\n        maxLength,\n        enterKeyHint: enterSends ? \"send\" : \"enter\",\n        \"aria-describedby\": describedBy,\n        onChange: (e) => setValue(e.target.value),\n        onKeyDown,\n        onPaste,\n      }}\n      attach={\n        onFiles\n          ? {\n              onClick: () => picker.current?.click(),\n              picker: (\n                <input\n                  ref={picker}\n                  type=\"file\"\n                  multiple\n                  accept={accept}\n                  hidden\n                  tabIndex={-1}\n                  aria-hidden=\"true\"\n                  onChange={(e) => {\n                    const picked = Array.from(e.target.files ?? []);\n                    e.target.value = \"\"; // so the same file can be picked again\n                    if (picked.length) onFiles(picked);\n                  }}\n                />\n              ),\n            }\n          : null\n      }\n      tools={tools}\n      count={near ? { id: countId, used: group(value.length), max: group(maxLength), full } : null}\n      send={{\n        ref: action,\n        streaming,\n        disabled: streaming ? disabled : !canSend,\n        onFocus: () => {\n          actionFocused.current = true;\n        },\n        onBlur: () => {\n          actionFocused.current = false;\n        },\n        onClick: () => {\n          if (streaming) onStop?.();\n          else send();\n          area.current?.focus();\n        },\n      }}\n      hint={hint ? { id: hintId, content: hint } : null}\n      status={\n        <>\n          <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n            {said}\n          </div>\n          <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n            {limitSaid}\n          </div>\n        </>\n      }\n    />\n  );\n}\n",
      "target": "components/litt/composer.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/composer.device.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/composer 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Composer, the Device body (proposals/worlds.md, Bodies; material.md, 8): a prompt line on LCD glass over a row of keys.\n *\n * - The box is a bezel (--litt-tray, --litt-tray-shade) with the glass set in it, 2px of body round the glass as the Input's. The\n *   glass is --litt-field with --litt-field-shade in the mono (font-litt-field), its corners the bezel's less 2px. Typed text is\n *   --litt-field-ink. A prompt mark (\">\") sits in the glass's left margin, an indicator and not a control, so it is\n *   aria-hidden. The text area, its growth and its keys are composer.tsx's.\n * - Attached files are lit cells on the glass: inverse video (--litt-field-ink behind --litt-field words) with a lamp, the name in\n *   the mono, the size, and a remove key. A sent message's file is a dark cell on the glass with a dim lamp ring.\n * - The toolbar is the bezel's key row below the glass: Attach and the caller's tools, then Send. Attach and Stop are\n *   keys in the body's grey (secondary), and Send is the one key in the accent (R6). A key has no word on it, its name is on the button (aria-label), as an icon-only Button's. The\n *   count is a small LCD cell with a lamp, which turns --litt-danger at the limit beside the words.\n * - Focus rings the bezel (--litt-focus-ring), a drag over it rings it in --litt-focus, and a disabled one lies flat on\n *   --litt-surface-2. They switch in three steps on the instant tier and the box never moves; nothing glides.\n * - The hint is a dim LCD line under the box, as the Input's. Forced colors drops shadows and fills, so the bezel and\n *   the glass get real borders, focus an outline, and every lamp a system color.\n */\nimport { AnimatePresence } from \"motion/react\";\nimport type { CSSProperties } from \"react\";\nimport { Button } from \"../button\";\nimport type { ComposerBodyProps, FileChipBodyProps } from \"../composer\";\nimport { ArrowUpIcon, PaperclipIcon, StopIcon, XIcon } from \"../icons\";\n\n/* The detent: rings and colors switch in three steps on the instant tier. */\nconst STEPS = \"steps(3, end)\";\nconst steps: CSSProperties = {\n  transitionProperty: \"box-shadow, color, background-color, opacity\",\n  transitionDuration: \"var(--litt-dur-instant)\",\n  transitionTimingFunction: `${STEPS}, ${STEPS}, ${STEPS}, ${STEPS}`,\n};\n\n/* The bezel carries the box's state; the text area inside is what takes focus. */\nconst BEZEL =\n  \"group/box relative flex flex-col overflow-hidden rounded-litt-surface bg-litt-tray p-0.5 shadow-(--litt-tray-shade) \" +\n  \"has-[textarea:focus-visible]:shadow-[var(--litt-tray-shade),var(--litt-focus-ring)] \" +\n  \"data-[dragging]:shadow-[var(--litt-tray-shade),0_0_0_2px_var(--litt-focus)] \" +\n  \"data-[disabled]:bg-litt-surface-2 data-[disabled]:shadow-[inset_0_0_0_1px_var(--litt-line)] \" +\n  \"forced-colors:border forced-colors:has-[textarea:focus-visible]:outline-2 forced-colors:has-[textarea:focus-visible]:outline-solid forced-colors:has-[textarea:focus-visible]:outline-offset-2\";\n\nconst GLASS =\n  \"relative flex flex-col rounded-[calc(var(--litt-radius-surface)-2px)] bg-litt-field font-litt-field font-normal text-litt-field-ink shadow-(--litt-field-shade) \" +\n  \"group-data-[disabled]/box:bg-transparent group-data-[disabled]/box:shadow-none forced-colors:border\";\n\nconst AREA =\n  \"block w-full min-w-0 resize-none overflow-y-hidden bg-transparent pr-litt-control-pad-md pl-1 py-3 font-litt-field font-normal text-litt-body text-litt-field-ink \" +\n  \"outline-none placeholder:text-litt-field-ink-subtle pointer-coarse:text-(length:--litt-control-text-lg) \" +\n  \"disabled:cursor-not-allowed disabled:text-litt-fg-disabled disabled:placeholder:text-litt-fg-disabled\";\n\nconst DROP =\n  \"pointer-events-none absolute inset-0 bg-litt-field font-litt-field pr-litt-control-pad-md pl-1 py-3 text-litt-body text-litt-field-ink-subtle \" +\n  \"pointer-coarse:text-(length:--litt-control-text-lg) opacity-0 group-data-[dragging]/box:opacity-100\";\n\n/** Device, the composer: a prompt line on LCD glass in a bezel, a row of keys under it. */\nexport function ComposerDeviceBody({ className, disabled, dragging, big, suggestions, drop, chips, label, areaId, area, attach, tools, count, send, hint, status, coarse }: ComposerBodyProps) {\n  const grow = \"pointer-coarse:h-litt-control-md max-sm:h-litt-control-md\";\n  const size = big ? \"md\" : \"sm\";\n  return (\n    <div data-body=\"device\" className={[\"flex min-w-0 flex-col gap-2 max-sm:pb-[env(safe-area-inset-bottom)]\", className].join(\" \")}>\n      {suggestions}\n\n      <div data-dragging={dragging ? \"\" : undefined} data-disabled={disabled ? \"\" : undefined} className={BEZEL} style={steps} {...drop}>\n        <div className={GLASS}>\n          <ul role=\"list\" aria-label=\"Attached files\" className={\"hidden flex-wrap has-[li]:flex has-[li]:px-1.5 has-[li]:pt-1.5 \" + (coarse ? \"gap-3\" : \"gap-2\")}>\n            <AnimatePresence initial={false}>{chips}</AnimatePresence>\n          </ul>\n\n          <div className=\"flex items-start\">\n            <span aria-hidden=\"true\" className=\"shrink-0 select-none py-3 pl-litt-control-pad-md text-litt-body text-litt-field-ink-subtle pointer-coarse:text-(length:--litt-control-text-lg)\">\n              &gt;\n            </span>\n            <div className=\"relative min-w-0 flex-1\">\n              <label htmlFor={areaId} className=\"sr-only\">\n                {label}\n              </label>\n              <textarea id={areaId} rows={1} className={AREA} {...area} />\n              <span aria-hidden=\"true\" className={DROP} style={steps}>\n                Drop to attach\n              </span>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center gap-2 p-1.5\">\n          {attach && (\n            <>\n              {attach.picker}\n              <Button variant=\"secondary\" size={size} icon={<PaperclipIcon />} aria-label=\"Attach files\" disabled={disabled} className={grow} onClick={attach.onClick} />\n            </>\n          )}\n          {tools && <div className=\"flex min-w-0 items-center gap-2\">{tools}</div>}\n          <div className=\"min-w-0 flex-1\" />\n          {count && (\n            <span\n              id={count.id}\n              style={steps}\n              className=\"flex shrink-0 items-center gap-1.5 rounded-litt-control bg-litt-field px-2 py-1 font-litt-field text-litt-caption font-normal tabular-nums text-litt-field-ink shadow-(--litt-field-shade) forced-colors:border\"\n            >\n              <span\n                aria-hidden=\"true\"\n                className={\n                  \"size-1.5 shrink-0 rounded-litt-pill \" +\n                  (count.full ? \"bg-litt-danger shadow-[0_0_6px_var(--litt-danger)] forced-colors:bg-[Highlight]\" : \"shadow-[inset_0_0_0_1px_var(--litt-field-ink-subtle)] forced-colors:border\")\n                }\n              />\n              <span className=\"sr-only\">Characters used: </span>\n              {count.used} / {count.max}\n            </span>\n          )}\n          <Button\n            ref={send.ref}\n            variant={send.streaming ? \"secondary\" : \"primary\"}\n            size={size}\n            icon={send.streaming ? <StopIcon /> : <ArrowUpIcon />}\n            aria-label={send.streaming ? \"Stop\" : \"Send\"}\n            disabled={send.disabled}\n            className={grow}\n            onFocus={send.onFocus}\n            onBlur={send.onBlur}\n            onClick={send.onClick}\n          />\n        </div>\n      </div>\n\n      {hint && (\n        <p id={hint.id} className=\"flex max-w-full font-litt-field text-litt-caption\">\n          <span className=\"w-fit max-w-full rounded-litt-surface bg-litt-field px-2.5 py-1 text-litt-field-ink-subtle shadow-(--litt-field-shade) forced-colors:border\">{hint.content}</span>\n        </p>\n      )}\n\n      {status}\n    </div>\n  );\n}\n\nconst REMOVE =\n  \"relative grid size-6 shrink-0 cursor-pointer place-items-center rounded-litt-control text-litt-field \" +\n  \"hover:bg-litt-field hover:text-litt-field-ink \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-field \" +\n  \"pointer-coarse:before:absolute pointer-coarse:before:-inset-2.5 forced-colors:border\";\n\n/** Device, the chip: a lit cell (inverse video, a lit lamp) while it can be removed, a dark cell with a dim lamp when it cannot. */\nexport function FileChipDeviceBody({ name, sizeText, uploading, percent, onRemove, coarse, className }: FileChipBodyProps) {\n  return (\n    <span\n      data-body=\"device\"\n      style={steps}\n      className={[\n        \"inline-flex h-8 max-w-full min-w-0 items-center gap-2 rounded-[calc(var(--litt-radius-surface)-8px)] pl-2.5 font-litt-field font-normal forced-colors:border\",\n        onRemove ? \"bg-litt-field-ink pr-1 text-litt-field\" : \"bg-litt-field pr-2.5 text-litt-field-ink shadow-(--litt-field-shade)\",\n        className,\n      ].join(\" \")}\n    >\n      <span\n        aria-hidden=\"true\"\n        className={\n          \"size-1.5 shrink-0 rounded-litt-pill \" +\n          (onRemove ? \"bg-litt-field shadow-[0_0_6px_var(--litt-field)] forced-colors:bg-[Highlight]\" : \"shadow-[inset_0_0_0_1px_var(--litt-field-ink-subtle)] forced-colors:border\")\n        }\n      />\n      <span className=\"min-w-0 truncate text-litt-caption\">{name}</span>\n      {uploading ? (\n        <>\n          <span className=\"sr-only\">, uploading {percent} percent</span>\n          <span aria-hidden=\"true\" className=\"min-w-[4ch] shrink-0 text-right text-litt-caption tabular-nums\">\n            {percent}%\n          </span>\n        </>\n      ) : (\n        sizeText !== null && (\n          <>\n            <span className=\"sr-only\">, </span>\n            <span className=\"shrink-0 text-litt-caption tabular-nums\">{sizeText}</span>\n          </>\n        )\n      )}\n      {onRemove && (\n        <button\n          type=\"button\"\n          aria-label={`Remove ${name}`}\n          className={REMOVE + (coarse ? \" before:absolute before:-inset-2.5\" : \"\")}\n          onClick={onRemove}\n        >\n          <XIcon />\n        </button>\n      )}\n    </span>\n  );\n}\n",
      "target": "components/litt/bodies/composer.device.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/composer.flat.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/composer 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Composer, Flat's body (proposals/worlds.md, Bodies): ink on paper. One grey pill that grows with the text.\n *\n * - The box is --litt-field (grey under Flat) with no shade, no line and no plate: a pill whose corners stop at half a\n *   one-line control, so it is a capsule while it holds a line or two and a soft block when it holds a page. Nothing\n *   is cut into it and nothing stands on it.\n * - Send is the round ink key (the Button's primary under Flat) at the end of the text's last line, so it rides down\n *   as the pill grows. It lies grey while the field is empty. Stop is the grey circle with a square.\n * - The tools are grey words (ghost buttons, no fill until the hand arrives) on a line under the text, and that\n *   line is there only when there is an Attach or a tool to put on it. A removable chip is a white bubble on the\n *   pill, a sent one a grey bubble. The count is a grey word at the end of that line.\n * - Focus is the ink ring, a drag over it the same ring, and a disabled one lies on --litt-surface-2.\n * - The contract (text area, keys, files, count, live regions) is composer.tsx's.\n */\nimport { AnimatePresence } from \"motion/react\";\nimport { Button } from \"../button\";\nimport type { ComposerBodyProps, FileChipBodyProps } from \"../composer\";\nimport { ArrowUpIcon, FileIcon, PaperclipIcon, StopIcon, XIcon } from \"../icons\";\n\nconst BOX =\n  \"group/box relative flex flex-col overflow-hidden rounded-litt-surface bg-litt-field font-litt-field font-normal text-litt-field-ink \" +\n  \"shadow-none transition-[box-shadow] duration-(--litt-dur-instant) ease-litt \" +\n  \"has-[textarea:focus-visible]:shadow-(--litt-focus-ring) data-[dragging]:shadow-(--litt-focus-ring) \" +\n  \"data-[disabled]:bg-litt-surface-2 data-[disabled]:shadow-none \" +\n  \"forced-colors:border forced-colors:has-[textarea:focus-visible]:outline-2 forced-colors:has-[textarea:focus-visible]:outline-solid forced-colors:has-[textarea:focus-visible]:outline-offset-2\";\n\n/* The text starts a little past the pill's curve, so the words clear the round end. */\nconst AREA =\n  \"block w-full min-w-0 resize-none overflow-y-hidden bg-transparent pl-[calc(var(--litt-control-pad-md)+4px)] pr-1 py-1 font-normal text-litt-body text-litt-field-ink \" +\n  \"outline-none placeholder:text-litt-field-ink-subtle pointer-coarse:py-2.5 pointer-coarse:text-(length:--litt-control-text-lg) \" +\n  \"disabled:cursor-not-allowed disabled:text-litt-fg-disabled disabled:placeholder:text-litt-fg-disabled\";\n\nconst DROP =\n  \"pointer-events-none absolute inset-0 bg-litt-field font-litt-field pl-[calc(var(--litt-control-pad-md)+4px)] pr-1 py-1 text-litt-body text-litt-field-ink-subtle \" +\n  \"pointer-coarse:py-2.5 pointer-coarse:text-(length:--litt-control-text-lg) opacity-0 transition-opacity duration-(--litt-dur-instant) ease-litt \" +\n  \"group-data-[dragging]/box:opacity-100\";\n\n/** Flat, the composer: a grey pill, the text and the ink Send in one line, grey words for the tools under it. */\nexport function ComposerFlatBody({ className, disabled, dragging, big, suggestions, drop, chips, label, areaId, area, attach, tools, count, send, hint, status, coarse }: ComposerBodyProps) {\n  const tall = \"pointer-coarse:h-litt-control-md max-sm:h-litt-control-md\";\n  const grow = tall + \" pointer-coarse:w-litt-control-md max-sm:w-litt-control-md\";\n  const size = big ? \"md\" : \"sm\";\n  return (\n    <div data-body=\"flat\" className={[\"flex min-w-0 flex-col gap-2 max-sm:pb-[env(safe-area-inset-bottom)]\", className].join(\" \")}>\n      {suggestions}\n\n      <div data-dragging={dragging ? \"\" : undefined} data-disabled={disabled ? \"\" : undefined} className={BOX} {...drop}>\n        <ul role=\"list\" aria-label=\"Attached files\" className={\"hidden flex-wrap has-[li]:flex has-[li]:px-2 has-[li]:pt-2 \" + (coarse ? \"gap-3\" : \"gap-2\")}>\n          <AnimatePresence initial={false}>{chips}</AnimatePresence>\n        </ul>\n\n        <div className=\"flex items-end gap-2 p-2\">\n          <div className=\"relative min-w-0 flex-1\">\n            <label htmlFor={areaId} className=\"sr-only\">\n              {label}\n            </label>\n            <textarea id={areaId} rows={1} className={AREA} {...area} />\n            <span aria-hidden=\"true\" className={DROP}>\n              Drop to attach\n            </span>\n          </div>\n          <Button\n            ref={send.ref}\n            variant={send.streaming ? \"secondary\" : \"primary\"}\n            size={size}\n            iconOnly\n            aria-label={send.streaming ? \"Stop\" : \"Send\"}\n            disabled={send.disabled}\n            className={grow}\n            onFocus={send.onFocus}\n            onBlur={send.onBlur}\n            onClick={send.onClick}\n          >\n            {send.streaming ? <StopIcon /> : <ArrowUpIcon />}\n          </Button>\n        </div>\n\n        {(attach || tools || count) && (\n          <div className=\"flex items-center gap-1 px-2 pb-2\">\n            {attach && (\n              <>\n                {attach.picker}\n                <Button variant=\"ghost\" size={size} icon={<PaperclipIcon />} aria-label=\"Attach files\" disabled={disabled} className={tall} onClick={attach.onClick}>\n                  Attach\n                </Button>\n              </>\n            )}\n            {tools && <div className=\"flex min-w-0 items-center gap-1\">{tools}</div>}\n            <div className=\"min-w-0 flex-1\" />\n            {count && (\n              <span\n                id={count.id}\n                className={\n                  \"shrink-0 px-2 text-litt-caption font-normal tabular-nums transition-colors duration-(--litt-dur-instant) ease-litt \" +\n                  (count.full ? \"text-litt-danger\" : \"text-litt-field-ink-subtle\")\n                }\n              >\n                <span className=\"sr-only\">Characters used: </span>\n                {count.used} / {count.max}\n              </span>\n            )}\n          </div>\n        )}\n      </div>\n\n      {hint && (\n        <p id={hint.id} className=\"px-litt-control-pad-md text-litt-caption font-normal text-litt-fg-subtle\">\n          {hint.content}\n        </p>\n      )}\n\n      {status}\n    </div>\n  );\n}\n\nconst REMOVE =\n  \"relative grid size-6 shrink-0 cursor-pointer place-items-center rounded-litt-control text-litt-fg-subtle \" +\n  \"transition-[color] duration-(--litt-dur-instant) ease-litt hover:text-litt-fg \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus \" +\n  \"pointer-coarse:before:absolute pointer-coarse:before:-inset-2.5\";\n\n/** Flat, the chip: a white bubble while it can be removed, a grey one when it cannot. */\nexport function FileChipFlatBody({ name, sizeText, uploading, percent, onRemove, coarse, className }: FileChipBodyProps) {\n  return (\n    <span\n      data-body=\"flat\"\n      className={[\n        \"inline-flex h-8 max-w-full min-w-0 items-center gap-2 rounded-litt-control pl-2 font-normal text-litt-fg\",\n        onRemove ? \"bg-litt-surface-1 pr-1 shadow-(--litt-block-lift) forced-colors:border\" : \"bg-litt-surface-3 pr-2 shadow-none\",\n        className,\n      ].join(\" \")}\n    >\n      <FileIcon className=\"shrink-0 text-litt-fg-muted\" />\n      <span className=\"min-w-0 truncate text-litt-caption font-normal text-litt-fg\">{name}</span>\n      {uploading ? (\n        <>\n          <span className=\"sr-only\">, uploading {percent} percent</span>\n          <span aria-hidden=\"true\" className=\"min-w-[4ch] shrink-0 text-right text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n            {percent}%\n          </span>\n        </>\n      ) : (\n        sizeText !== null && (\n          <>\n            <span className=\"sr-only\">, </span>\n            <span className=\"shrink-0 text-litt-caption tabular-nums font-normal text-litt-fg-subtle\">{sizeText}</span>\n          </>\n        )\n      )}\n      {onRemove && (\n        <button\n          type=\"button\"\n          aria-label={`Remove ${name}`}\n          className={REMOVE + (coarse ? \" before:absolute before:-inset-2.5\" : \"\")}\n          onClick={onRemove}\n        >\n          <XIcon />\n        </button>\n      )}\n    </span>\n  );\n}\n",
      "target": "components/litt/bodies/composer.flat.tsx"
    }
  ],
  "docs": "Import Composer (and FileChip) from components/litt/composer. Hold the files yourself and pass onSend, streaming and onStop: the field clears after a send, and Send becomes Stop while streaming is true. Pass onFiles to show Attach and take pastes and drops, files and onRemoveFile to draw the chips, tools for a model Select at sm, suggestions for an empty chat, hint for a line under the box, and maxLength for the count. On a touch screen Return adds a line and Send sends by itself (enterSends overrides it); keep the composer above the keyboard with useVisibleScreen.",
  "meta": {
    "version": "2026.11.09",
    "released": "2026-11-09",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
