{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-composer",
  "type": "registry:component",
  "title": "Message composer",
  "description": "Composes a compact growing draft with validated file previews, paste and drop, screenshot capture, and retained drafts on failure.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/textarea.json",
    "https://vlak.dev/r/button.json",
    "https://vlak.dev/r/icons.json",
    "https://vlak.dev/r/attachments.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/message-composer.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { useMergedRefs } from \"./merge-refs\";\nimport { Textarea } from \"./textarea\";\nimport { Button } from \"./button\";\nimport { Icon } from \"./icons\";\nimport { Attachment, Attachments, useFileAttachments, type AttachmentData } from \"./attachments\";\nimport { selectFiles, type FileSelectionRejection } from \"./file-selection\";\nimport { captureScreenshot } from \"./capture-screenshot\";\n\nexport interface ComposedMessage { text: string; files: File[] }\nexport type MessageAttachmentRejection = FileSelectionRejection;\nexport interface MessageComposerAttachmentActions {\n  remove: (id: string) => void;\n  openFileDialog: () => void;\n  disabled: boolean;\n}\nexport interface MessageComposerState {\n  value: string;\n  files: readonly File[];\n  attachments: readonly AttachmentData[];\n  /** Changes the draft unless submission or the composer is disabled. */\n  setValue: (value: string) => void;\n  /** Uses the same validation as the file picker, paste, and drop. */\n  addFiles: (files: readonly File[] | FileList) => boolean;\n  removeAttachment: (id: string) => void;\n  clearAttachments: () => void;\n  openFileDialog: () => void;\n  captureScreenshot: () => Promise<void>;\n  focus: () => void;\n  /** Returns false when blocked, invalid, or sending fails. */\n  submit: () => Promise<boolean>;\n  stop: () => void;\n  disabled: boolean;\n  pending: boolean;\n  generating: boolean;\n  canSubmit: boolean;\n  canAttach: boolean;\n  canCapture: boolean;\n  capturing: boolean;\n  status: string;\n  failed: boolean;\n}\nexport interface MessageComposerParts {\n  /** Render once; owns the merged ref, shortcuts, and compact auto sizing. */\n  input: React.ReactNode;\n  submit: React.ReactNode;\n  attach: React.ReactNode;\n  screenshot: React.ReactNode;\n  attachments: React.ReactNode;\n  tools: React.ReactNode;\n}\nexport type MessageComposerTextareaProps = Omit<React.ComponentPropsWithRef<\"textarea\">, \"value\" | \"defaultValue\" | \"disabled\" | \"placeholder\" | \"maxLength\" | \"rows\" | \"children\">;\nconst MessageComposerContext = React.createContext<MessageComposerState | null>(null);\n\n/** Reads the nearest composer's draft and validated commands from a custom child or tool. */\nexport function useMessageComposer(): MessageComposerState {\n  const context = React.useContext(MessageComposerContext);\n  if (!context) throw new Error(\"useMessageComposer must be used inside MessageComposer.\");\n  return context;\n}\nexport interface MessageComposerProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, \"defaultValue\" | \"onSubmit\"> {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (text: string) => void;\n  onSend: (message: ComposedMessage) => void | Promise<void>;\n  label?: string;\n  placeholder?: string;\n  disabled?: boolean;\n  allowAttachments?: boolean;\n  accept?: string;\n  /** Controlled file selection, independent of the text draft. */\n  files?: File[];\n  defaultFiles?: File[];\n  onFilesChange?: (files: File[]) => void;\n  multiple?: boolean;\n  maxFiles?: number;\n  maxFileSize?: number;\n  onAttachmentError?: (rejections: MessageAttachmentRejection[]) => void;\n  /** Also accept file drops outside this composer. Enable on one composer per page. */\n  globalDrop?: boolean;\n  /** Shows a user-activated browser screen capture action when available. */\n  allowScreenshot?: boolean;\n  /** Application-owned model selectors, capability controls, or other tools. */\n  tools?: React.ReactNode;\n  renderAttachments?: (attachments: AttachmentData[], actions: MessageComposerAttachmentActions) => React.ReactNode;\n  /** Rearranges the provided controls. Render input and submit once; feedback stays in the form. */\n  renderLayout?: (parts: MessageComposerParts, state: MessageComposerState) => React.ReactNode;\n  /** Native textarea attributes and handlers. Prevent a key's default to override its shortcut. */\n  textareaProps?: MessageComposerTextareaProps;\n  maxLength?: number;\n  /** Enter submits, Shift+Enter inserts a line. Otherwise use Cmd/Ctrl+Enter. */\n  sendOnEnter?: boolean;\n  /** Application-owned response generation, separate from submission pending state. */\n  generating?: boolean;\n  /** Requests that the application stop generation; does not itself cancel a network request. */\n  onStop?: () => void;\n  /** A single-line draft with an inline icon action; grows as the message wraps. */\n  compact?: boolean;\n  /** Maximum visible draft lines in compact mode, clamped to 1–20. */\n  maxRows?: number;\n}\nconst styles = stylex.create({\n  root: { display: \"flex\", flexDirection: \"column\", width: \"100%\", minWidth: 0, gap: \"0.75rem\", color: vlak.ink },\n  compact: { gap: \"0.5rem\" },\n  row: { display: \"flex\", alignItems: \"flex-end\", gap: \"0.25rem\", padding: \"0.25rem\", minWidth: 0, borderWidth: vlak.hairline, borderStyle: \"solid\", borderColor: vlak.controlBorder, borderRadius: vlak.radiusSm, backgroundColor: vlak.paper },\n  area: {\n    boxSizing: \"border-box\", flex: \"1 1 0%\", width: \"100%\", minWidth: 0, minHeight: vlak.hit,\n    fontFamily: \"inherit\", fontSize: \"1rem\", lineHeight: 1.375, color: vlak.ink,\n    backgroundColor: \"transparent\", borderWidth: 0, borderRadius: vlak.radiusSm,\n    paddingBlock: \"0.6875rem\", paddingInline: \"0.5rem\", resize: \"none\", overflowY: \"hidden\",\n    outlineWidth: { default: 0, \":focus-visible\": 2 },\n    outlineStyle: { default: \"none\", \":focus-visible\": \"solid\" },\n    outlineColor: vlak.ink, outlineOffset: -2,\n  },\n  actions: { display: \"flex\", flexWrap: \"wrap\", alignItems: \"center\", justifyContent: \"space-between\", gap: \"0.75rem\" },\n  action: { width: \"auto\", minWidth: vlak.hit, minHeight: vlak.hit, paddingInline: \"0.875rem\" },\n  iconAction: { flex: \"0 0 auto\", flexBasis: vlak.hit, width: { default: vlak.hit, [mq.phone]: vlak.hit }, maxWidth: vlak.hit, height: vlak.hit, minHeight: vlak.hit, paddingInline: { default: 0, [mq.phone]: 0 }, alignSelf: \"flex-end\" },\n  files: { minWidth: 0 },\n  file: { minWidth: 0 },\n  tools: { display: \"flex\", flexWrap: \"wrap\", alignItems: \"center\", gap: \"0.5rem\", minWidth: 0 },\n  drag: { outlineWidth: 2, outlineStyle: \"dashed\", outlineColor: vlak.ink, outlineOffset: 4 },\n  hint: { margin: 0, fontSize: \"0.75rem\", lineHeight: 1.45, color: vlak.gray },\n  input: { display: \"none\" },\n  srOnly: { position: \"absolute\", width: 1, height: 1, padding: 0, margin: -1, overflow: \"hidden\", clipPath: \"inset(50%)\", whiteSpace: \"nowrap\", borderWidth: 0 },\n});\n\n/** A message draft with attachments, IME-safe shortcuts, and retained text after send failures. */\nexport const MessageComposer = React.forwardRef<HTMLTextAreaElement, MessageComposerProps>(function MessageComposer({ value, defaultValue = \"\", onValueChange, onSend, label = \"Message\", placeholder = \"Write a message…\", disabled = false, allowAttachments = false, accept, files: controlledFiles, defaultFiles = [], onFilesChange, multiple = true, maxFiles, maxFileSize, onAttachmentError, globalDrop = false, allowScreenshot = false, tools, renderAttachments, renderLayout, textareaProps = {}, children, maxLength, sendOnEnter = false, generating = false, onStop, compact = false, maxRows = 6, className, style, ...props }, ref) {\n  const [inner, setInner] = React.useState(defaultValue);\n  const current = value ?? inner;\n  const [innerFiles, setInnerFiles] = React.useState<File[]>(defaultFiles);\n  const files = controlledFiles ?? innerFiles;\n  const previews = useFileAttachments(files);\n  const [rejections, setRejections] = React.useState<MessageAttachmentRejection[]>([]);\n  const [dragging, setDragging] = React.useState(false);\n  const [capturing, setCapturing] = React.useState(false);\n  const [captureAvailable, setCaptureAvailable] = React.useState(false);\n  const capturedStream = React.useRef<MediaStream | null>(null);\n  const captureAbort = React.useRef<AbortController | null>(null);\n  const mounted = React.useRef(true);\n  const sendVersion = React.useRef(0);\n  const [pending, setPending] = React.useState(false);\n  const sending = React.useRef(false);\n  const [status, setStatus] = React.useState(\"\");\n  const [failed, setFailed] = React.useState(false);\n  const areaRef = React.useRef<HTMLTextAreaElement>(null);\n  const mergedRef = useMergedRefs(areaRef, ref, textareaProps.ref);\n  const composing = React.useRef(false);\n  const fileRef = React.useRef<HTMLInputElement>(null);\n  const helpId = React.useId();\n  const blocked = disabled || pending;\n  const currentDraft = React.useRef({ text: current, files });\n  currentDraft.current = { text: current, files };\n  const setFiles = (next: File[]) => {\n    if (controlledFiles === undefined) { currentDraft.current.files = next; setInnerFiles(next); }\n    onFilesChange?.(next);\n  };\n  React.useEffect(() => {\n    mounted.current = true;\n    setCaptureAvailable(typeof navigator.mediaDevices?.getDisplayMedia === \"function\");\n    return () => { mounted.current = false; sendVersion.current++; captureAbort.current?.abort(); for (const track of capturedStream.current?.getTracks() ?? []) track.stop(); };\n  }, []);\n  const addFiles = (incoming: readonly File[] | FileList) => {\n    if (blocked || sending.current || !allowAttachments) return false;\n    const { accepted, added, rejected } = selectFiles(Array.from(incoming), { files: currentDraft.current.files, accept, multiple, maxFiles, maxSize: maxFileSize });\n    setRejections(rejected);\n    if (rejected.length) onAttachmentError?.(rejected);\n    if (added.length) setFiles(accepted);\n    return added.length > 0;\n  };\n  const attachmentState = React.useRef({ addFiles, blocked, allowAttachments });\n  attachmentState.current = { addFiles, blocked, allowAttachments };\n  React.useEffect(() => {\n    if (!globalDrop || !allowAttachments) return;\n    const over = (event: DragEvent) => { if (!attachmentState.current.blocked && event.dataTransfer?.types.includes(\"Files\")) event.preventDefault(); };\n    const drop = (event: DragEvent) => {\n      if (event.defaultPrevented || attachmentState.current.blocked || !event.dataTransfer?.files.length) return;\n      event.preventDefault(); attachmentState.current.addFiles(Array.from(event.dataTransfer.files));\n    };\n    document.addEventListener(\"dragover\", over); document.addEventListener(\"drop\", drop);\n    return () => { document.removeEventListener(\"dragover\", over); document.removeEventListener(\"drop\", drop); };\n  }, [globalDrop, allowAttachments]);\n  const takeScreenshot = async () => {\n    if (blocked || sending.current || capturing || captureAbort.current || !allowAttachments || !allowScreenshot || !captureAvailable) return;\n    const controller = new AbortController();\n    captureAbort.current = controller;\n    setCapturing(true); setFailed(false); setStatus(\"Choose a screen or window to attach.\");\n    try {\n      const file = await captureScreenshot((stream) => { capturedStream.current = stream; if (!mounted.current) for (const track of stream.getTracks()) track.stop(); }, controller.signal);\n      if (mounted.current) setStatus(attachmentState.current.addFiles([file]) ? \"Screenshot attached.\" : \"\");\n    } catch { if (mounted.current) { setFailed(true); setStatus(\"Screen capture was cancelled or could not be attached.\"); } }\n    finally { capturedStream.current = null; captureAbort.current = null; if (mounted.current) setCapturing(false); }\n  };\n  const rows = Number.isFinite(maxRows) ? Math.min(20, Math.max(1, Math.floor(maxRows))) : 6;\n  const resize = React.useCallback(() => {\n    const area = areaRef.current;\n    if (!compact || !area) return;\n    const computed = window.getComputedStyle(area);\n    const number = (property: string) => Number.parseFloat(property) || 0;\n    const lineHeight = number(computed.lineHeight) || number(computed.fontSize) * 1.375 || 22;\n    const padding = number(computed.paddingTop) + number(computed.paddingBottom);\n    const border = number(computed.borderTopWidth) + number(computed.borderBottomWidth);\n    const minimum = number(computed.minHeight) || 44;\n    const maximum = Math.max(minimum, rows * lineHeight + padding + border);\n    // Release the previous height before measuring so shorter and cleared drafts shrink.\n    area.style.height = \"auto\";\n    const content = area.scrollHeight + border;\n    area.style.height = `${Math.max(minimum, Math.min(content, maximum))}px`;\n    area.style.maxHeight = `${maximum}px`;\n    area.style.overflowY = content > maximum ? \"auto\" : \"hidden\";\n  }, [compact, rows]);\n  // biome-ignore lint/correctness/useExhaustiveDependencies: Draft changes alter the textarea's measured scroll height.\n  React.useEffect(() => { resize(); }, [resize, current]);\n  React.useEffect(() => {\n    const area = areaRef.current;\n    if (!compact || !area || typeof ResizeObserver === \"undefined\") return;\n    let width = area.clientWidth;\n    const observer = new ResizeObserver(() => {\n      const nextWidth = area.clientWidth;\n      // Height changes are our own work; only remeasure when wrapping width changes.\n      if (nextWidth === width) return;\n      width = nextWidth;\n      resize();\n    });\n    observer.observe(area);\n    return () => observer.disconnect();\n  }, [compact, resize]);\n  const change = (next: string) => { if (value === undefined) { currentDraft.current.text = next; setInner(next); } onValueChange?.(next); };\n  const submit = async (): Promise<boolean> => {\n    const draft = currentDraft.current;\n    if (blocked || capturing || captureAbort.current || generating || sending.current || (!draft.text.trim() && draft.files.length === 0)) return false;\n    if (areaRef.current && !areaRef.current.reportValidity()) return false;\n    const snapshot = { text: draft.text, files: [...draft.files] };\n    const request = ++sendVersion.current;\n    sending.current = true; setPending(true); setFailed(false); setStatus(\"Sending…\");\n    try {\n      await onSend({ text: snapshot.text.trim(), files: snapshot.files });\n      if (!mounted.current || request !== sendVersion.current) return false;\n      if (currentDraft.current.text === snapshot.text) change(\"\");\n      setFiles(currentDraft.current.files.filter((file) => !snapshot.files.includes(file)));\n      setRejections([]); setStatus(\"Message sent.\");\n      return true;\n    } catch { if (mounted.current && request === sendVersion.current) { setFailed(true); setStatus(\"The message could not be sent. Your draft is still here.\"); } return false; }\n    finally { if (mounted.current && request === sendVersion.current) { sending.current = false; setPending(false); areaRef.current?.focus(); } }\n  };\n  const root = rs([\"rs-message-composer\", compact && \"rs-message-composer-compact\", dragging && \"rs-message-composer-drag\", className], styles.root, compact && styles.compact, dragging && styles.drag);\n  const row = rs([\"rs-message-composer-row\"], styles.row);\n  const area = rs([\"rs-message-composer-area\"], styles.area);\n  const actions = rs([\"rs-message-composer-actions\"], styles.actions);\n  const action = rs([\"rs-message-composer-action\", compact && \"rs-message-composer-icon-action\"], styles.action, compact && styles.iconAction);\n  const fileList = rs([\"rs-message-composer-files\"], styles.files);\n  const toolRow = rs([\"rs-message-composer-tools\"], styles.tools);\n  const file = rs([\"rs-message-composer-file\"], styles.file);\n  const hint = rs([\"rs-message-composer-hint\", compact && \"rs-message-composer-sr-only\"], styles.hint, compact && styles.srOnly);\n  const feedback = rs([\"rs-message-composer-hint\", compact && !failed && \"rs-message-composer-sr-only\"], styles.hint, compact && !failed && styles.srOnly);\n  const input = rs([\"rs-message-composer-input\"], styles.input);\n  const rejection = rs([\"rs-message-composer-hint\"], styles.hint);\n  const draftProps = {\n    ...textareaProps,\n    ref: mergedRef, value: current,\n    onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => { textareaProps.onChange?.(event); if (!event.defaultPrevented) change(event.target.value); },\n    placeholder, disabled: blocked, maxLength, \"aria-describedby\": [textareaProps[\"aria-describedby\"], helpId].filter(Boolean).join(\" \"),\n    onCompositionStart: (event: React.CompositionEvent<HTMLTextAreaElement>) => { composing.current = true; textareaProps.onCompositionStart?.(event); },\n    onCompositionEnd: (event: React.CompositionEvent<HTMLTextAreaElement>) => { composing.current = false; textareaProps.onCompositionEnd?.(event); },\n    onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => {\n      textareaProps.onKeyDown?.(event);\n      if (event.defaultPrevented || composing.current || event.nativeEvent.isComposing || event.keyCode === 229 || event.key !== \"Enter\") return;\n      if ((sendOnEnter && !event.shiftKey) || event.metaKey || event.ctrlKey) { event.preventDefault(); void submit(); }\n    },\n  };\n  const openFileDialog = () => { if (!blocked && !sending.current && allowAttachments) fileRef.current?.click(); };\n  const attachAction = allowAttachments && <Button {...action} variant=\"subtle\" aria-label={compact ? \"Attach files\" : undefined} title={compact ? \"Attach files\" : undefined} disabled={blocked} onClick={openFileDialog}><Icon name=\"attachment\" />{!compact && \"Attach\"}</Button>;\n  const removeAttachment = (id: string) => { const index = previews.findIndex((item) => item.id === id); if (index >= 0 && !blocked && !sending.current) setFiles(currentDraft.current.files.filter(entry => entry !== files[index])); };\n  const captureAction = allowAttachments && allowScreenshot && <Button {...action} variant=\"subtle\" aria-label=\"Attach screenshot\" title={captureAvailable ? \"Attach screenshot\" : \"Screen capture is unavailable in this browser\"} disabled={blocked || capturing || !captureAvailable} onClick={() => void takeScreenshot()}><Icon name=\"camera\" />{!compact && (capturing ? \"Capturing…\" : \"Screenshot\")}</Button>;\n  const sendLabel = pending ? \"Sending…\" : \"Send\";\n  const sendAction = generating\n    ? <Button {...action} type=\"button\" aria-label={compact ? \"Stop response\" : undefined} title={compact ? \"Stop response\" : undefined} disabled={disabled || !onStop} onClick={onStop}><Icon name=\"stop\" />{!compact && \"Stop response\"}</Button>\n    : <Button {...action} type=\"submit\" aria-label={compact ? sendLabel : undefined} title={compact ? sendLabel : undefined} disabled={blocked || capturing || (!current.trim() && files.length === 0)}><Icon name=\"send\" />{!compact && sendLabel}</Button>;\n  const draftInput = compact ? <textarea {...draftProps} className={[area.className, textareaProps.className].filter(Boolean).join(\" \")} style={{ ...area.style, ...textareaProps.style }} rows={1} aria-label={textareaProps[\"aria-label\"] ?? label} /> : <Textarea {...draftProps} label={label} />;\n  const attachmentList = files.length > 0 && <div {...fileList}>{renderAttachments ? renderAttachments(previews, { remove: removeAttachment, openFileDialog, disabled: blocked }) : <Attachments>{previews.map((data) => <Attachment {...file} key={data.id} data={data} disabled={blocked} onRemove={() => removeAttachment(data.id)} />)}</Attachments>}</div>;\n  const state: MessageComposerState = {\n    value: current, files, attachments: previews,\n    setValue: next => { if (!blocked && !sending.current) change(next); },\n    addFiles, removeAttachment, clearAttachments: () => { if (!blocked && !sending.current) { setFiles([]); setRejections([]); } },\n    openFileDialog, captureScreenshot: takeScreenshot, focus: () => areaRef.current?.focus(), submit,\n    stop: () => { if (!disabled && generating) onStop?.(); },\n    disabled: blocked, pending, generating, capturing,\n    canSubmit: !blocked && !capturing && !generating && (current.trim().length > 0 || files.length > 0),\n    canAttach: allowAttachments && !blocked,\n    canCapture: allowAttachments && allowScreenshot && captureAvailable && !blocked && !capturing,\n    status, failed,\n  };\n  const parts: MessageComposerParts = { input: draftInput, submit: sendAction, attach: attachAction, screenshot: captureAction, attachments: attachmentList, tools };\n  return <MessageComposerContext.Provider value={state}><form aria-label={label} aria-busy={pending || generating} {...props} className={root.className} style={{ ...root.style, ...style }} onSubmit={event => { event.preventDefault(); void submit(); }}\n    onDragOver={event => { props.onDragOver?.(event); if (!event.defaultPrevented && allowAttachments && !blocked && event.dataTransfer.types.includes(\"Files\")) { event.preventDefault(); setDragging(true); } }}\n    onDragLeave={event => { props.onDragLeave?.(event); if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}\n    onDrop={event => { props.onDrop?.(event); setDragging(false); if (!event.defaultPrevented && allowAttachments && !blocked && event.dataTransfer.files.length) { event.preventDefault(); addFiles(Array.from(event.dataTransfer.files)); } }}\n    onPaste={event => { props.onPaste?.(event); if (!event.defaultPrevented && allowAttachments && !blocked && event.clipboardData.files.length) { if (!event.clipboardData.getData(\"text/plain\")) event.preventDefault(); addFiles(Array.from(event.clipboardData.files)); } }}>\n    {allowAttachments && <input {...input} ref={fileRef} type=\"file\" multiple={multiple} accept={accept} tabIndex={-1} aria-label=\"Attach files\" disabled={blocked} onChange={event => { addFiles(Array.from(event.target.files ?? [])); event.target.value = \"\"; }} />}\n    {renderLayout ? renderLayout(parts, state) : <>\n      {compact ? <div {...row}>{draftInput}{attachAction}{sendAction}</div> : draftInput}\n      {attachmentList}\n      {!compact && <div {...actions}><div {...toolRow}>{attachAction}{captureAction}</div>{sendAction}</div>}\n      {(tools || (compact && captureAction)) && <div {...toolRow}>{compact && captureAction}{tools}</div>}\n    </>}\n    {children}\n    {rejections.length > 0 && <div role=\"alert\">{rejections.map(({ file, reason }, index) => <p {...rejection} key={`${file.name}-${index}`}>{file.name}: {reason}</p>)}</div>}\n    <p {...hint} id={helpId}>{sendOnEnter ? \"Enter to send. Shift+Enter for a new line.\" : \"Cmd or Ctrl+Enter to send.\"}</p>\n    <p {...feedback} role=\"status\">{status}</p>\n  </form></MessageComposerContext.Provider>;\n});\n",
      "type": "registry:component",
      "target": "components/vlak/message-composer.tsx"
    },
    {
      "path": "vlak/file-selection.ts",
      "content": "/** Shared browser-file validation for file upload and message composition. */\nexport interface FileSelectionRejection {\n  file: File;\n  code: \"accept\" | \"max_files\" | \"max_file_size\";\n  reason: string;\n}\nexport const fileSelectionKey = (file: File) => `${file.name}:${file.size}:${file.lastModified}`;\nexport function acceptsFile(file: File, accept?: string) {\n  if (!accept?.trim()) return true;\n  return accept.split(\",\").some((entry) => {\n    const rule = entry.trim().toLowerCase();\n    if (!rule) return false;\n    if (rule.startsWith(\".\")) return file.name.toLowerCase().endsWith(rule);\n    if (rule.endsWith(\"/*\")) return file.type.toLowerCase().startsWith(rule.slice(0, -1));\n    return file.type.toLowerCase() === rule;\n  });\n}\nexport function selectFiles(incoming: readonly File[], { files, accept, multiple = true, maxFiles, maxSize }: {\n  files: readonly File[]; accept?: string; multiple?: boolean; maxFiles?: number; maxSize?: number;\n}) {\n  const accepted = multiple ? [...files] : [];\n  const added: File[] = [];\n  const rejected: FileSelectionRejection[] = [];\n  const limit = multiple ? maxFiles : 1;\n  for (const file of incoming) {\n    if (accepted.some((entry) => fileSelectionKey(entry) === fileSelectionKey(file))) continue;\n    const code = !acceptsFile(file, accept) ? \"accept\" : maxSize != null && file.size > maxSize ? \"max_file_size\" : limit != null && accepted.length >= limit ? \"max_files\" : undefined;\n    if (code) rejected.push({ file, code, reason: code === \"accept\" ? \"File type is not accepted\" : code === \"max_file_size\" ? `File exceeds ${maxSize} bytes` : `Choose at most ${limit} ${limit === 1 ? \"file\" : \"files\"}` });\n    else { accepted.push(file); added.push(file); }\n  }\n  return { accepted, added, rejected };\n}\n",
      "type": "registry:file",
      "target": "components/vlak/file-selection.ts"
    },
    {
      "path": "vlak/capture-screenshot.ts",
      "content": "/** Capture one user-selected display frame. The caller must invoke this from an explicit user action. */\nexport async function captureScreenshot(onStream: (stream: MediaStream) => void, signal?: AbortSignal): Promise<File> {\n  const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });\n  onStream(stream);\n  const video = document.createElement(\"video\");\n  try {\n    if (signal?.aborted) throw new Error(\"Capture cancelled\");\n    if (stream.getVideoTracks().every((track) => track.readyState === \"ended\")) throw new Error(\"Capture cancelled\");\n    video.muted = true;\n    video.srcObject = stream;\n    await new Promise<void>((resolve, reject) => {\n      const timeout = window.setTimeout(() => finish(new Error(\"The screen preview did not become available.\")), 10_000);\n      const abort = () => finish(new Error(\"Capture cancelled\"));\n      const finish = (error?: Error) => {\n        window.clearTimeout(timeout);\n        video.onloadeddata = null; video.onerror = null;\n        signal?.removeEventListener(\"abort\", abort);\n        error ? reject(error) : resolve();\n      };\n      signal?.addEventListener(\"abort\", abort, { once: true });\n      video.onloadeddata = () => finish();\n      video.onerror = () => finish(new Error(\"The screen preview could not be read.\"));\n      void video.play().catch(() => finish(new Error(\"The screen preview could not be started.\")));\n    });\n    if (!video.videoWidth || !video.videoHeight) throw new Error(\"The screen preview is empty.\");\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = video.videoWidth; canvas.height = video.videoHeight;\n    const context = canvas.getContext(\"2d\");\n    if (!context) throw new Error(\"Screen capture is unavailable.\");\n    context.drawImage(video, 0, 0);\n    const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error(\"The screen preview could not be saved.\")), \"image/png\"));\n    return new File([blob], \"screenshot.png\", { type: \"image/png\" });\n  } finally {\n    for (const track of stream.getTracks()) track.stop();\n    video.pause(); video.srcObject = null;\n  }\n}\n",
      "type": "registry:file",
      "target": "components/vlak/capture-screenshot.ts"
    },
    {
      "path": "vlak/styles/message-composer.css",
      "content": "/* ── message-composer: generated from packages/react/src/components/message-composer.tsx ── */\n.rs-message-composer{display:flex;flex-direction:column;width:100%;min-width:0;gap:0.75rem;color:var(--text)}\n.rs-message-composer-compact{gap:0.5rem}\n.rs-message-composer-row{display:flex;align-items:flex-end;gap:0.25rem;padding:0.25rem;min-width:0;border-width:1px;border-style:solid;border-color:var(--control-border);border-radius:var(--radius-sm);background-color:var(--bg)}\n.rs-message-composer-area{box-sizing:border-box;flex:1 1 0%;width:100%;min-width:0;min-height:var(--hit);font-family:inherit;font-size:1rem;line-height:1.375;color:var(--text);background-color:transparent;border-width:0;border-radius:var(--radius-sm);padding-block:0.6875rem;padding-inline:0.5rem;resize:none;overflow-y:hidden;outline-width:0;outline-style:none;outline-color:var(--text);outline-offset:-2px}\n.rs-message-composer-area:focus-visible{outline-width:2px;outline-style:solid}\n.rs-message-composer-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:0.75rem}\n.rs-message-composer-action{width:auto;min-width:var(--hit);min-height:var(--hit);padding-inline:0.875rem}\n.rs-message-composer-icon-action{flex:0 0 auto;flex-basis:var(--hit);width:var(--hit);max-width:var(--hit);height:var(--hit);min-height:var(--hit);padding-inline:0;align-self:flex-end}\n@media (max-width: 640px){.rs-message-composer-icon-action{width:var(--hit);padding-inline:0}}\n.rs-message-composer-files{min-width:0}\n.rs-message-composer-file{min-width:0}\n.rs-message-composer-tools{display:flex;flex-wrap:wrap;align-items:center;gap:0.5rem;min-width:0}\n.rs-message-composer-drag{outline-width:2px;outline-style:dashed;outline-color:var(--text);outline-offset:4px}\n.rs-message-composer-hint{margin:0;font-size:0.75rem;line-height:1.45;color:var(--text-secondary)}\n.rs-message-composer-input{display:none}\n.rs-message-composer-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border-width:0}\n",
      "type": "registry:file",
      "target": "styles/vlak/message-composer.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "patterns",
      "classes": [
        "rs-message-composer",
        "rs-message-composer-compact",
        "rs-message-composer-row",
        "rs-message-composer-area",
        "rs-message-composer-actions",
        "rs-message-composer-action",
        "rs-message-composer-icon-action",
        "rs-message-composer-files",
        "rs-message-composer-file",
        "rs-message-composer-hint",
        "rs-message-composer-input",
        "rs-message-composer-sr-only",
        "rs-message-composer-tools",
        "rs-message-composer-drag"
      ],
      "snippet": "<form class=\"rs-message-composer\" aria-label=\"Message\"><label>Message<textarea class=\"rs-textarea\" placeholder=\"Write a message…\"></textarea></label><div class=\"rs-message-composer-actions\"><button class=\"rs-btn-primary\" type=\"submit\">Send</button></div></form>",
      "cssOnly": false,
      "registryDependencies": [
        "textarea",
        "button",
        "icons",
        "attachments",
        "vlak-lib"
      ],
      "aliases": [
        "MessageComposer",
        "Chat input",
        "Comment composer",
        "AI Elements PromptInput",
        "Prompt input",
        "AI chat composer"
      ],
      "example": "\"use client\";\nimport { Button, MessageComposer, useMessageComposer, type ComposedMessage } from \"@noorddev/vlak-react\";\n\nfunction ClearDraft() {\n  const draft = useMessageComposer();\n  return <Button variant=\"subtle\" size=\"sm\" disabled={draft.disabled || !draft.value}\n    onClick={() => { draft.setValue(\"\"); draft.focus(); }}>Clear draft</Button>;\n}\n\nexport function ProjectComposer({ onSend, generating = false, onStop }: {\n  onSend: (message: ComposedMessage) => void | Promise<void>;\n  generating?: boolean;\n  onStop?: () => void;\n}) {\n  return <MessageComposer compact maxRows={6} sendOnEnter onSend={onSend}\n    generating={generating} onStop={onStop} tools={<ClearDraft />}\n    textareaProps={{ \"aria-label\": \"Ask about the project\", autoComplete: \"off\" }}\n    allowAttachments accept=\"image/*,.pdf\" maxFiles={4}\n    maxFileSize={10 * 1024 * 1024} allowScreenshot />;\n}",
      "usage": {
        "use": [
          "Chat, comments, or a support reply.",
          "Async submission that must preserve a draft when it fails.",
          "Application-owned response generation with a Stop response action.",
          "Use compact for a single-line draft with inline icon actions. The field grows with wrapping and newlines, then scrolls at maxRows, which defaults to six lines.",
          "The compact field shrinks when text is removed or a successful send clears it, and remeasures when its available width changes.",
          "Use files, defaultFiles, and onFilesChange to lift attachment selection alongside controlled text.",
          "Picker, paste, and drop share accept, maxFiles, maxFileSize, and multiple validation. onAttachmentError receives rejected files and reasons.",
          "Use tools for model selectors or application controls, and renderAttachments for custom preview composition.",
          "useMessageComposer reads the current draft, files, previews, pending and generation state, and validated commands from a descendant. It requires a surrounding MessageComposer.",
          "renderLayout receives MessageComposerParts and state to arrange input, submit, attach, screenshot, attachments, and tools. Render input and submit once; hidden file selection and feedback remain owned by the form.",
          "textareaProps forwards native attributes, events, and a merged ref to the actual field. Prevent a keyboard event default to override its shortcut; required draft and submission behavior stay in the composer.",
          "Enable globalDrop on one composer per page when file drops outside the field should attach there. allowScreenshot adds explicit browser screen selection when supported."
        ],
        "avoid": [
          "An arbitrary multi-field form or uploading files without a message.",
          "Calling useMessageComposer outside its provider or invoking commands while rendering. Run commands from user actions or application effects.",
          "Assuming browser file checks replace the application upload policy or that attachment selection uploads files automatically."
        ]
      },
      "keyboard": [
        {
          "keys": "Cmd+Enter, Ctrl+Enter",
          "does": "Submits the draft when no send or response generation is in progress."
        },
        {
          "keys": "Enter, Shift+Enter",
          "does": "With sendOnEnter enabled, Enter submits and Shift+Enter inserts a line; IME composition never submits."
        },
        {
          "keys": "Tab, Enter, Space",
          "does": "Operates attachment, send, and stop actions."
        }
      ],
      "a11y": [
        "The textarea has a visible label by default. Compact mode uses the same accessible name and a visually hidden shortcut description.",
        "Sending prevents duplicate submission; results are announced.",
        "Only a successful send clears the draft and attachments; failures retain both.",
        "generating replaces Send with Stop response but keeps the next draft editable. onStop requests application cancellation; it does not cancel network activity itself.",
        "Compact icon actions retain readable names, titles, and 44px targets. Failed sends remain visible beneath the field; other status updates are available to assistive technology.",
        "Rejected attachments remain visible in an alert. File previews have named remove controls and preserve object addresses until removal.",
        "Screenshot capture starts only from its button, uses the browser chooser, and stops display tracks after capture, cancellation, or unmount.",
        "If controlled text or files change during a pending send, successful completion clears only the submitted draft and files that still match.",
        "Custom layouts retain the form, validation, shortcuts, and feedback. Keep the provided field and action names and their natural reading order."
      ]
    }
  }
}
