{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ios-composer",
  "title": "iOS composer",
  "description": "The glass message field with the plus button, microphone, auto-growing text, and the send pill.",
  "files": [
    {
      "path": "registry/imessage/ios-composer.tsx",
      "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useRef, useState, type ComponentProps, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * iOS 26 composer row, measured from `references/ios/captures/conv3-light.png`, `conv2-dark.png`,\n * `paste-check.png` and `newmsg-typed-disabled-send-light.png` (402pt screen). The row hugs the\n * bottom of the screen with 28pt margins, and both surfaces rest their bottom edge on y 846:\n *\n * - `+` glass circle Ø40 centered (48, 826), so its top edge is y 806. Glyph ink 15.33 square, and\n *   its bar integrates to 4.99 device px of coverage, i.e. 1.66 of stroke. The 1.6 drawn below\n *   rasterizes to the same 4.99, so the glyph lands on the capture pixel for pixel.\n * - Field x 80–374. It is **40.33** tall for one line, not 40: in `conv2-dark.png` its rim starts a\n *   device pixel above the `+` button's (y 805.67 against 806) while both end at 846. Each extra\n *   line adds exactly 20 (`newmsg-typed-disabled-send-light.png` two lines = 60.33, `paste-check.png`\n *   four lines = 100.33), so the extra third of a point is a one-off at the top edge, not per line.\n *   The box is laid out at that height, but Chromium rounds a painted box edge to the whole CSS\n *   pixel, so the rim still lands on 806 on the screen. Measured every way round: a fractional\n *   border-radius, a clip-path and a composited layer all snap the same. It is the one device pixel\n *   of the composer that a browser will not draw.\n * - The field's corner is a plain **circular** radius 20, not a continuous corner. Tracing the outer\n *   edge in `conv2-dark.png` (one line) and `paste-check.png` (four lines) and fitting a browser\n *   render to it: `corner-shape: round` sits within 0.06pt of native, `superellipse(1.14)` cuts the\n *   corner 0.55–0.78pt too tight all the way down it. At one line the field is a capsule anyway.\n * - Text 17pt on a 20pt line, 10 top and bottom over a 16pt left inset (placeholder ink x 97–167.67,\n *   ink top 819.33 in `conv3-light.png`, matched to the pixel). Caret #0088ff light / #0091ff dark.\n * - Mic: ink 11.87 × 17.85 at x 346.85–358.72, y 816.95–834.8 (capsule 6.03 × 11.65, U 11.87 across,\n *   every stroke 1.25). Colour #b4b8bf light / #636466 dark. It has its own outline rather than\n *   reusing `IosMicIcon`, which is the heavier weight the conversation list's search pill draws.\n * - Send pill 38×28 #0088ff, its right edge 6.33 in from the field's right edge and its bottom 6\n *   above the field's bottom, in a two-line and a four-line field alike, so it is bottom-anchored.\n *   White arrow: 2.41 stroke, apex (348.33, 819), arms out to ±5.88, stem down to y 832.7. The path\n *   is drawn a sixth of a point left and a third down of those, which is what lands the rendered ink\n *   on the capture: the pill's own box starts at x 329.67 and the browser rasterizes the SVG from a\n *   rounded origin.\n * - Glass: 90% white with a backdrop blur and a soft shadow (light), #191919 with a 1pt rim (dark);\n *   shadows are painted on a layer beneath both surfaces and clipped at the midpoint of the 12pt\n *   gap, so the field never shades the `+` button.\n *\n * All four buttons respond to a finger - they dim and shrink. Neither number is in the captures; see\n * `iosComposerPress` for where they come from and what is borrowed.\n *\n * Not measured: `maxLines` (no capture holds a field taller than four lines) and the gray send pill\n * a composer with no recipient shows (#ededee with a #b8b8bb arrow in\n * `newmsg-typed-disabled-send-light.png`), which this component does not render.\n */\nexport type IosComposerProps = Omit<ComponentProps<\"form\">, \"onSubmit\" | \"onChange\" | \"defaultValue\"> & {\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  onSend?: (message: string) => void | Promise<void>;\n  /** Show Send when attachments are ready, even without a text caption. */\n  hasAttachments?: boolean;\n  onAttach?: () => void;\n  /** Whether the attachments sheet (`IosPlusMenu`) is open; sets the `+` button's aria-expanded. */\n  attachExpanded?: boolean;\n  onMic?: () => void;\n  placeholder?: string;\n  disabled?: boolean;\n  /**\n   * The screen has an editing session open - a caret is somewhere, the keyboard is up. iOS lifts the\n   * composer while that is true: the field fills a shade darker and the shadow under it strengthens.\n   * See `composerLift`.\n   */\n  raised?: boolean;\n  /** Tallest the field grows before it scrolls, in lines. */\n  maxLines?: number;\n};\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\nconst LINE = 20;\nconst PAD_Y = 10;\n/** The field's top edge sits a device pixel above the `+` button's, so one line is 40.33 and not 40. */\nconst TOP_EDGE = 1 / 3;\n\nconst vars =\n  \"[--ios-cmp-glass:rgba(255,255,255,0.9)] [--ios-cmp-rim:none] [--ios-cmp-shadow:0_6px_36px_4px_rgba(0,0,0,0.065)] [--ios-cmp-shadow-round:0_5px_20px_6px_rgba(0,0,0,0.055)] [--ios-cmp-glyph:#1a1919] [--ios-cmp-text:#000000] [--ios-cmp-placeholder:#bdbdbd] [--ios-cmp-mic:#b4b8bf] [--ios-cmp-caret:#0088ff] \" +\n  \"dark:[--ios-cmp-glass:rgba(28,28,28,0.9)] dark:[--ios-cmp-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)] dark:[--ios-cmp-shadow:none] dark:[--ios-cmp-shadow-round:none] dark:[--ios-cmp-glyph:#f4f3f4] dark:[--ios-cmp-text:#ffffff] dark:[--ios-cmp-placeholder:#5d5d5d] dark:[--ios-cmp-mic:#636466] dark:[--ios-cmp-caret:#0091ff]\";\n\n/**\n * The composer fills a shade darker while an editing session is open, and the captures separate that\n * cleanly from everything else it might have been confused with.\n *\n * `conv3-light.png` and `list-light.png` - no caret anywhere on the screen - have the field at 255.\n * `paste-check.png` has a caret in the composer and `newmsg-light.png` has one in the To: field\n * while its own composer sits empty and unfocused; both read 253. So the trigger is the session, not\n * a full field and not composer focus. Both were shot on a simulator with a hardware keyboard, which\n * is why no software keyboard gives it away.\n *\n * The shadow is **not** part of this, which took three captures to establish. Measuring the darkness\n * below the composer's bottom edge at 0, 2.7, 5.3 … 24 pt: conv3, list and newmsg all read\n * 11, 10, 9, 8, 7, 6, 5, 4, and only `paste-check` reads 18, 17, 15, 13, 12, 10, 8, 7 - the same\n * curve scaled by 1.601 (least squares over 28,969 pixels). What is different about paste-check is\n * that its field is four lines tall, so the extra shadow follows the box's height rather than the\n * session. A CSS `box-shadow` does grow with its box, but not by nearly that much; closing the\n * remainder needs a capture of a two-line composer to tell a height law from a step.\n */\nconst composerLift = \"[--ios-cmp-glass:rgba(253,253,253,0.9)]\";\n\n/**\n * What a finger does to the four buttons on this row.\n *\n * **Borrowed, not measured.** No capture in this repo holds a pressed control, so the two numbers\n * come from ChatKit, and they are the only two it has: grepping the framework's whole selector table\n * for `touchAlpha|touchScale|pressedAlpha|pressedScale|highlightAlpha|highlightScale|dimAlpha|\n * touchDownAlpha|touchDownScale` returns `replyButtonTouchAlpha` = 0.4 and `replyButtonTouchScale`\n * = 0.85 and nothing else. Read at both idioms with the swizzle in place, `CKUIBehaviorPhone`\n * (idiom 0) and `CKUIBehaviorMac` (idiom 5) agree on both.\n *\n * They belong to a different button, but to the nearest one ChatKit records: the reply button is\n * itself a blurred glass pill (`replyButtonBackgroundBlurRadius` 5, `replyButtonBorderWidth` 0.5),\n * and ChatKit applies both to the whole view on `_buttonTouchDown`, which is what the `+` circle here\n * is. iOS 26 renders the pressed state of a `glassButtonConfiguration` button in UIKit, not in\n * ChatKit, so there is no closer number to find.\n *\n * The dim rides on the button's own opacity, so it comes off the **measured glass already on the\n * element** - `--ios-cmp-glass` and its shadow and rim fade together - rather than off a second\n * pressed fill that no capture pins. What ChatKit does record about this row corroborates the rest:\n * `_entryViewButtonTintColor` reads #B4B8BF light, the mic colour measured from `conv3-light.png`.\n *\n * `release` is **unmeasured** - nothing in this repo records a control in motion. Press-down is\n * instant, which is what a direct-manipulation highlight is; only the release eases back.\n */\nexport const iosComposerPress = { alpha: 0.4, scale: 0.85, release: 100 };\n\n/**\n * Press state that a finger, a mouse and the keyboard all reach.\n *\n * Pointer events rather than `:active`: Chrome holds `:active` back on a touch until the gesture has\n * resolved into a tap rather than a scroll, so a finger that is still down is not reliably styled and\n * a driven `Input.dispatchTouchEvent` hold cannot be proved. The pointer is captured so the release\n * always lands back here, and while it is held the press follows the pointer out of the button's own\n * box, which is what iOS does when a finger slides off a control.\n *\n * A finger that slides off does not come back, and that is Chromium rather than this hook: driving a\n * real touch 60 px off the `+` logs `pointerdown -> pointermove(outside) -> touchmove ->\n * pointercancel -> lostpointercapture`, i.e. gesture arbitration takes the pointer away the moment\n * the finger pans, and the touchmove back onto the control delivers no pointer event at all. A mouse\n * keeps its pointer, so dragging off a held button drops the press and dragging back on takes it up\n * again.\n *\n * Nothing here moves focus, deliberately: both engines match `:focus-visible` on a programmatic focus\n * taken while a pointer is still down, so a press opened by a finger would draw a ring iOS never\n * draws (`tapback-bar.tsx` and `audio-recorder.tsx` carry the two fixes for the places that do have\n * to move focus). `send()` still returns the caret to the field afterwards, but that runs a frame\n * after the finger has already lifted.\n */\nfunction usePress() {\n  const [pressed, setPressed] = useState(false);\n  const held = useRef(false);\n  const release = () => { held.current = false; setPressed(false); };\n  return {\n    pressed,\n    handlers: {\n      onPointerDown(event: PointerEvent<HTMLElement>) {\n        if (event.pointerType === \"mouse\" && event.button !== 0) return;\n        held.current = true;\n        try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* the pointer ended before this handler ran */ }\n        setPressed(true);\n      },\n      onPointerMove(event: PointerEvent<HTMLElement>) {\n        if (!held.current) return;\n        const box = event.currentTarget.getBoundingClientRect();\n        setPressed(event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom);\n      },\n      onPointerUp: release,\n      onPointerCancel: release,\n      onLostPointerCapture: release,\n      onKeyDown(event: KeyboardEvent<HTMLElement>) { if (event.key === \" \" || event.key === \"Enter\") setPressed(true); },\n      onKeyUp: release,\n      onBlur: release,\n    },\n  };\n}\n\n/** Instant down, eased back up; `data-pressed` turns the transition off so the dim lands on the frame the finger does. */\nconst pressTransition = \"transition-[opacity,transform] ease-out data-pressed:transition-none motion-reduce:transition-none\";\n\n/** The pressed pose, or nothing at all at rest so a settled composer renders byte for byte as before. */\nfunction pressStyle(pressed: boolean) {\n  return { transitionDuration: `${iosComposerPress.release}ms`, opacity: pressed ? iosComposerPress.alpha : undefined, transform: pressed ? `scale(${iosComposerPress.scale})` : undefined };\n}\n\n/** `clip` stops the shadow at the midpoint of the 12pt gap toward a neighboring glass element, so the two shadows read as one (they never add up on the device). */\nfunction GlassLayers({ round = false, clip }: { round?: boolean; clip?: \"left\" | \"right\" }) {\n  return (\n    <>\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 -z-10 rounded-[inherit]\" style={{ boxShadow: round ? \"var(--ios-cmp-shadow-round)\" : \"var(--ios-cmp-shadow)\", clipPath: clip ? `inset(-60px ${clip === \"right\" ? \"-6px\" : \"-60px\"} -60px ${clip === \"left\" ? \"-6px\" : \"-60px\"})` : undefined }} />\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-[inherit]\" style={{ background: \"var(--ios-cmp-glass)\", boxShadow: \"var(--ios-cmp-rim)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\" }} />\n    </>\n  );\n}\n\n/**\n * SF \"mic\" as the conversation list's search pill draws it: a heavier weight than the composer's,\n * 1.60 of stroke on 18.33 of ink. Kept for `ios-conversation-list.tsx`, which is the only caller.\n */\nexport function IosMicIcon({ width = 12, height = 17.6667, color = \"currentColor\" }: { width?: number; height?: number; color?: string }) {\n  return (\n    <svg aria-hidden=\"true\" width={width} height={height} viewBox=\"0 0 12 17.6667\" fill=\"none\" stroke={color} strokeLinecap=\"round\">\n      <rect x=\"3.6667\" y=\"0.6667\" width=\"4.6667\" height=\"10.3333\" rx=\"2.3333\" strokeWidth=\"1.3333\" />\n      <path d=\"M0.75 6.3333V8.83A5.08 5.08 0 0 0 10.92 8.83V6.3333\" strokeWidth=\"1.5\" />\n      <path d=\"M5.83 14.5V16.3\" strokeWidth=\"1.5\" />\n      <path d=\"M2.3 16.9H9.4\" strokeWidth=\"1.3333\" />\n    </svg>\n  );\n}\n\n/**\n * The composer's mic, traced separately from `conv3-light.png` at 3x because iOS draws the symbol\n * lighter here than in the search pill: capsule 6.03 × 11.65, U 11.87 across, base bar 8.62, every\n * stroke 1.25. The viewBox is the ink box. One shared outline cannot serve both: drawing the\n * composer with the pill's leaves 81 mismatched pixels here against `conv3-light.png` where this\n * one leaves 0, and drawing the pill with this one takes its own 30pt window from 21 to 26.\n */\nfunction ComposerMicIcon() {\n  return (\n    <svg aria-hidden=\"true\" width=\"11.87\" height=\"17.85\" viewBox=\"0 0 11.87 17.85\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.25\" strokeLinecap=\"round\">\n      <rect x=\"3.535\" y=\"0.625\" width=\"4.78\" height=\"10.4\" rx=\"2.39\" />\n      <path d=\"M0.625 7.23V9.02A5.31 5.31 0 0 0 11.245 9.02V7.23\" />\n      <path d=\"M5.935 14.33V17.225\" />\n      <path d=\"M2.24 17.225H9.61\" />\n    </svg>\n  );\n}\n\nexport function IosComposer({\n  value, defaultValue = \"\", onChange, onSend, hasAttachments = false, onAttach, attachExpanded, onMic, placeholder = \"Message\", disabled = false, raised = false, maxLines = 8, className, style, ...props\n}: IosComposerProps) {\n  const [draft, setDraft] = useState(defaultValue);\n  const text = value ?? draft;\n  const textarea = useRef<HTMLTextAreaElement>(null);\n  const composing = useRef(false);\n  const sending = useRef(false);\n  const id = useId();\n  const hasText = text.trim().length > 0;\n  const attach = usePress();\n  const mic = usePress();\n  const sendPress = usePress();\n\n  function update(next: string) {\n    if (value === undefined) setDraft(next);\n    onChange?.(next);\n  }\n\n  // Grow with the content, one 20pt line at a time, up to `maxLines`.\n  useLayoutEffect(() => {\n    const el = textarea.current;\n    if (!el) return;\n    el.style.height = \"0px\";\n    const max = maxLines * LINE + PAD_Y * 2;\n    const next = Math.max(LINE + PAD_Y * 2, Math.min(el.scrollHeight, max));\n    el.style.height = `${next}px`;\n    el.style.overflowY = el.scrollHeight > max ? \"auto\" : \"hidden\";\n  }, [text, maxLines]);\n\n  async function send() {\n    const message = text.trim();\n    if ((!message && !hasAttachments) || disabled || sending.current || composing.current) return;\n    sending.current = true;\n    try {\n      await onSend?.(message);\n      update(\"\");\n    } finally {\n      sending.current = false;\n      requestAnimationFrame(() => textarea.current?.focus());\n    }\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {\n    if (event.key !== \"Enter\" || event.shiftKey) return;\n    if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229 || composing.current) return;\n    event.preventDefault();\n    void send();\n  }\n\n  return (\n    <form data-slot=\"ios-composer\" data-raised={raised || undefined} aria-label=\"Send a message\" className={cn(\"relative isolate flex w-full items-end select-none\", vars, raised && composerLift, className)}\n      style={{ padding: \"0 28px 28px 28px\", fontFamily: font, ...style }} onSubmit={event => { event.preventDefault(); void send(); }} {...props}>\n      <button type=\"button\" data-slot=\"attach\" aria-label=\"Add attachment\" aria-haspopup=\"menu\" aria-expanded={attachExpanded} disabled={disabled} onClick={onAttach}\n        data-pressed={attach.pressed || undefined} {...attach.handlers}\n        className={cn(\"relative flex shrink-0 items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-blue-500 disabled:opacity-50\", pressTransition)}\n        style={{ width: 40, height: 40, ...pressStyle(attach.pressed) }}>\n        <GlassLayers round clip=\"right\" />\n        <svg aria-hidden=\"true\" className=\"relative\" width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" fill=\"none\" stroke=\"var(--ios-cmp-glyph)\" strokeWidth=\"1.6\" strokeLinecap=\"round\">\n          <path d=\"M13.1 20H26.9M20 13.1V26.9\" />\n        </svg>\n      </button>\n      <div data-slot=\"field\" className=\"relative min-w-0 flex-1\" style={{ marginLeft: 12, paddingTop: TOP_EDGE, minHeight: LINE + PAD_Y * 2 + TOP_EDGE, borderRadius: 20 }}>\n        <GlassLayers clip=\"left\" />\n        <label htmlFor={id} className=\"sr-only\">Message</label>\n        <textarea ref={textarea} id={id} rows={1} value={text} disabled={disabled} placeholder={placeholder}\n          onChange={event => update(event.target.value)} onKeyDown={onKeyDown}\n          onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}\n          className=\"relative block w-full resize-none border-0 bg-transparent outline-none select-text placeholder:text-[var(--ios-cmp-placeholder)]\"\n          style={{ padding: `${PAD_Y}px 48px ${PAD_Y}px 16px`, fontFamily: font, fontSize: 17, lineHeight: `${LINE}px`, letterSpacing: 0, color: \"var(--ios-cmp-text)\", caretColor: \"var(--ios-cmp-caret)\", boxSizing: \"border-box\", margin: 0 }} />\n        {hasText || hasAttachments ? (\n          <button type=\"submit\" data-slot=\"send\" aria-label=\"Send message\" disabled={disabled}\n            data-pressed={sendPress.pressed || undefined} {...sendPress.handlers}\n            className={cn(\"absolute flex items-center justify-center rounded-full bg-[#0088ff] text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500\", pressTransition)}\n            style={{ right: 6.3333, bottom: 6, width: 38, height: 28, ...pressStyle(sendPress.pressed) }}>\n            <svg aria-hidden=\"true\" width=\"38\" height=\"28\" viewBox=\"0 0 38 28\" fill=\"none\" stroke=\"#ffffff\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path d=\"M12.6333 12.6667 18.5 7.3333 24.3667 12.6667M18.5 7.3333V20.7\" />\n            </svg>\n          </button>\n        ) : (\n          <button type=\"button\" data-slot=\"mic\" aria-label=\"Record audio message\" disabled={disabled} onClick={onMic}\n            data-pressed={mic.pressed || undefined} {...mic.handlers}\n            className={cn(\"absolute flex items-center justify-center focus-visible:outline-2 focus-visible:outline-blue-500\", pressTransition)}\n            /* 20×26 hit box; the insets centre the 11.87×17.85 glyph on its measured ink, x 346.85–358.72 and y 816.95–834.8 in a one-line field. */\n            style={{ right: 11.22, bottom: 7.13, width: 20, height: 26, color: \"var(--ios-cmp-mic)\", ...pressStyle(mic.pressed) }}>\n            <ComposerMicIcon />\n          </button>\n        )}\n      </div>\n    </form>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/message-ui/ios-composer.tsx"
    }
  ],
  "type": "registry:ui"
}
