{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sentence-form",
  "type": "registry:ui",
  "title": "Sentence Form",
  "description": "A contact form written as a sentence. Blanks stretch as you type, picked options fly up into place while the words make room, and on send the form settles into the sentence you wrote.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/sentence-form.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  motionValue,\n  useReducedMotion,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * SENTENCE FORM STORYBOARD\n *\n *  rest     a sentence with blanks in it, wrapping like any text.\n *           An empty blank shows a muted hint on a dotted underline\n *  focus    a soft highlight settles on the blank you're in and\n *           glides to the next one as you move. Its underline\n *           darkens and thickens. The tray under the sentence\n *           names the blank, gives a hint and shows its options,\n *           swapping them on a short stagger\n *  type     a text blank stretches with its text, keeping its\n *           placeholder's room while you're in it. Words on their\n *           line are pushed along at once; a word bumped to another\n *           line hops into place. Leaving the blank lets it tighten\n *           to its text, and the words after it glide back\n *  pick     the option's label flies up out of its chip into the\n *           blank, growing to the sentence's size without\n *           stretching, while the words after it glide aside on\n *           the same spring. Each line shifts as one piece; a word\n *           that no longer fits slides off the end of its line and\n *           in at the start of the next, never across the lines in\n *           between. The chip keeps an outline with a faint imprint\n *           of the label; tap it to send the pick back down\n *  swap     picking another option in a pick-one blank flies the\n *           new one up as the old one drops back into its outline.\n *           A beat later, focus moves on to the next empty blank\n *  clause   words that appear rise in one after another once the\n *           words after them have made room; words that go fold\n *           away and the gap closes\n *  invalid  on send, the first blank that needs attention takes\n *           focus, turns red and shakes, and the tray says why\n *  pending  the button shows it's sending and the blanks hold still\n *  sent     underlines retract, hints go, and the answers become\n *           plain words that wrap like the rest, so the form settles\n *           into the note you wrote. A confirmation rises where the\n *           button was\n *  edit     the same choreography in reverse, with every answer kept\n *  keys     Tab moves through the blanks. Enter, Space or ↓ on a\n *           pick blank moves into its options: arrows move, Enter\n *           or Space picks (in a pick-any blank Space toggles and\n *           Enter finishes). Esc goes back to the blank, Tab on to\n *           the next. Enter in a text blank goes to the next empty\n *           blank, or sends when none are left\n *  touch    options are full-size tap targets, and the active blank\n *           and its tray keep themselves in view above the keyboard\n *  reduced  nothing flies, glides or shakes: things take their\n *           places at once and only fade\n * ───────────────────────────────────────────────────────── */\n\nconst SENTENCE = {\n  flow: { type: \"spring\", visualDuration: 0.45, bounce: 0 }, // flights and the reflow around them\n  typed: { type: \"spring\", visualDuration: 0.22, bounce: 0 }, // a word hopping lines while you type\n  hop: 0.5, // em a word hopping lines while you type travels\n  beat: 380, // ms after a pick-one choice before focus moves on, about when its flight lands\n  rise: 0.4, // em a word in a new clause rises from\n  settle: 0.14, // s a word appearing waits for the words leaving its spot to clear\n  stagger: 0.045, // s between words rising into a clause\n  options: 0.03, // s between options arriving in the tray\n  fade: { duration: 0.2, ease: \"easeOut\" },\n  fold: 200, // ms for a leaving word to fold away\n  underline: { duration: 0.4, ease: [0.22, 1, 0.36, 1] },\n  shake: [0, -7, 6, -4, 2, 0], // px\n  space: 0.25, // em, about one word space, for placing an answer's words as it splits up\n  glow: { x: 0.14, y: 0.02 }, // em the highlight reaches past its blank\n} as const;\n\n/* ── Types ──────────────────────────────────────────────── */\n\n/** A string per blank, or a string[] for blanks with `multiple`. */\nexport type SentenceFormValues = Record<string, string | string[]>;\n\n/** An option is its label, or `{ value, label }` when the submitted value differs. */\nexport type SentenceFormOption = string | { value: string; label: string };\n\ntype Option = { value: string; label: string };\ntype Field = Omit<SentenceFormFieldProps, \"options\"> & { options?: Option[] };\ntype FieldError = { kind: \"required\" | \"format\"; message: string };\ntype Status = \"editing\" | \"pending\" | \"sent\" | \"failed\";\n\n/* ── Reading the sentence ───────────────────────────────── */\n\n// The sentence is flattened into atoms (words, other elements and fields)\n// with a null wherever there was whitespace, so a line may break there.\n// Atoms that touch with no space between them (\".\" after a blank) are kept\n// together. Ids come from where an atom sits in the JSX, so a clause that\n// appears doesn't change the ids of the words after it.\n\ntype Atom =\n  | { kind: \"text\"; id: string; text: string }\n  | { kind: \"node\"; id: string; node: React.ReactElement }\n  | { kind: \"field\"; id: string; field: Field };\ntype Flow = (Atom | null)[];\n\nconst toOption = (option: SentenceFormOption): Option =>\n  typeof option === \"string\" ? { value: option, label: option } : option;\n\nfunction walk(node: React.ReactNode, path: string, out: Flow) {\n  if (node == null || typeof node === \"boolean\") return;\n  if (typeof node === \"string\" || typeof node === \"number\" || typeof node === \"bigint\") {\n    String(node)\n      .split(/(\\s+)/)\n      .forEach((bit, i) => {\n        if (bit) out.push(/^\\s/.test(bit) ? null : { kind: \"text\", id: `${path}:${i}`, text: bit });\n      });\n    return;\n  }\n  if (Array.isArray(node)) {\n    node.forEach((child, i) => {\n      const key = React.isValidElement(child) && child.key != null ? `$${child.key}` : i;\n      walk(child, `${path}.${key}`, out);\n    });\n    return;\n  }\n  if (!React.isValidElement(node)) return;\n  if (node.type === React.Fragment) {\n    walk((node.props as { children?: React.ReactNode }).children, path, out);\n    return;\n  }\n  if (node.type === SentenceFormField) {\n    const props = node.props as SentenceFormFieldProps;\n    out.push({\n      kind: \"field\",\n      id: `f:${props.name}`,\n      field: { ...props, options: props.options?.map(toOption) },\n    });\n    return;\n  }\n  out.push({ kind: \"node\", id: path, node });\n}\n\nfunction parse(content: React.ReactNode) {\n  const raw: Flow = [];\n  walk(content, \"s\", raw);\n  const flow: Flow = [];\n  for (const atom of raw) {\n    if (atom === null && (flow.length === 0 || flow[flow.length - 1] === null)) continue;\n    flow.push(atom);\n  }\n  if (flow[flow.length - 1] === null) flow.pop();\n  const fields = flow.flatMap((atom) => (atom?.kind === \"field\" ? [atom.field] : []));\n  return { flow, fields };\n}\n\n/** Splits a flow at its spaces into runs that can't be broken. */\nfunction group<T extends { id: string }>(flow: (T | null)[]) {\n  const units: { key: string; atoms: T[] }[] = [];\n  let run: T[] = [];\n  const close = () => {\n    if (run.length) units.push({ key: run[0].id, atoms: run });\n    run = [];\n  };\n  for (const atom of flow) {\n    if (atom === null) close();\n    else run.push(atom);\n  }\n  close();\n  return units;\n}\n\n/**\n * What a screen reader hears between the controls. The visible words are\n * split up for the animation and hidden from assistive tech; these runs put\n * the sentence back together around the fields.\n */\nfunction speech(flow: Flow, said: (field: Field) => string | null) {\n  const before = new Map<string, string>();\n  let text = \"\";\n  for (const atom of flow) {\n    if (atom === null) text += \" \";\n    else if (atom.kind === \"text\") text += atom.text;\n    else {\n      const words = atom.kind === \"field\" ? said(atom.field) : null;\n      if (words !== null) {\n        text += words;\n        continue;\n      }\n      before.set(atom.id, text.trim());\n      text = \"\";\n    }\n  }\n  return { before, after: text.trim() };\n}\n\nconst formats = new Map<string, Intl.ListFormat>();\nfunction listFormat(locale: string) {\n  let format = formats.get(locale);\n  if (!format) {\n    format = new Intl.ListFormat(locale, { style: \"long\", type: \"conjunction\" });\n    formats.set(locale, format);\n  }\n  return format;\n}\n\ntype ListBit =\n  | { kind: \"pick\"; id: string; value: string; label: string }\n  | { kind: \"word\"; id: string; text: string };\n\n/**\n * A pick-any blank's answer as words: its picks joined with the locale's\n * list grammar. Punctuation that touches a pick is keyed to that pick; the\n * free-standing words (\"and\") are keyed by count, so the \"and\" glides along\n * as picks are added instead of fading out and back in.\n */\nfunction listBits(field: Field, picks: string[], locale: string): (ListBit | null)[] {\n  const parts = listFormat(locale).formatToParts(picks.map((value) => labelOf(field, value)));\n  const out: (ListBit | null)[] = [];\n  let n = 0;\n  let free = 0;\n  let previous = \"\";\n  for (const part of parts) {\n    if (part.type === \"element\") {\n      previous = picks[n++];\n      out.push({ kind: \"pick\", id: `f:${field.name}:v:${previous}`, value: previous, label: part.value });\n      continue;\n    }\n    let touching = true;\n    part.value.split(/(\\s+)/).forEach((bit, i) => {\n      if (!bit) return;\n      if (/^\\s/.test(bit)) {\n        out.push(null);\n        touching = false;\n        return;\n      }\n      const id = touching ? `f:${field.name}:after:${previous}:${i}` : `f:${field.name}:and:${free++}`;\n      out.push({ kind: \"word\", id, text: bit });\n    });\n  }\n  return out;\n}\n\n/** The next empty blank after this one, or else a required one before it. */\nfunction nextEmpty(from: string, fields: Field[], valueOf: (name: string) => string | string[]) {\n  const i = fields.findIndex((field) => field.name === from);\n  const empty = (field: Field) => isEmpty(valueOf(field.name));\n  return (\n    fields.slice(i + 1).find(empty) ?? fields.slice(0, Math.max(i, 0)).find((field) => field.required && empty(field))\n  )?.name;\n}\n\nconst labelOf = (field: Field, value: string) =>\n  field.options?.find((option) => option.value === value)?.label ?? value;\n\nconst isEmpty = (value: string | string[] | undefined) =>\n  Array.isArray(value) ? value.length === 0 : !value?.trim();\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\nfunction looksLikeUrl(text: string) {\n  try {\n    return new URL(/^[a-z][\\w+.-]*:/i.test(text) ? text : `https://${text}`).hostname.includes(\".\");\n  } catch {\n    return false;\n  }\n}\n\nfunction check(field: Field, value: string | string[], typing: boolean): FieldError | null {\n  if (Array.isArray(value)) {\n    return field.required && value.length === 0\n      ? { kind: \"required\", message: \"Pick at least one to send.\" }\n      : null;\n  }\n  const text = value.trim();\n  if (!text) {\n    if (!field.required) return null;\n    if (field.options && !typing) return { kind: \"required\", message: \"Pick one to send.\" };\n    return { kind: \"required\", message: \"Fill this in to send.\" };\n  }\n  if (field.type === \"email\" && !EMAIL.test(text)) {\n    return { kind: \"format\", message: \"That doesn't look like an email address.\" };\n  }\n  if (field.type === \"url\" && !looksLikeUrl(text)) {\n    return { kind: \"format\", message: \"That doesn't look like a web address.\" };\n  }\n  return null;\n}\n\n/* ── Motion engine ──────────────────────────────────────── */\n\n// Every word, answer, option label and the blocks under the sentence are\n// registered here by id. A change snapshots where everything is on screen;\n// after React commits, each element is measured at its new place and moved\n// back to where it was, then springs home (FLIP). A new element whose\n// partner just left (an option label and the answer it becomes) flies from\n// the partner's spot instead, scaled uniformly so its text never stretches.\n// Elements that leave are cloned into a layer under the sentence to fade or\n// fold away. Layout changes without a snapshot (a resize, a late font) just\n// reflow.\n\ntype Kind = \"word\" | \"chip\" | \"block\";\ntype Enter = \"rise\" | \"fade\" | \"none\";\ntype Exit = \"fold\" | \"fade\" | \"none\";\ntype Mode = \"flow\" | \"type\";\ntype Box = { left: number; top: number; width: number; height: number };\ntype PieceOptions = { kind: Kind; partner?: string; origin?: string; enter: Enter; exit: Exit };\ntype Entry = PieceOptions & {\n  id: string;\n  el: HTMLElement;\n  x: MotionValue<number>;\n  y: MotionValue<number>;\n  s: MotionValue<number>;\n  /** While a word slides in at the start or end of a line: the text column's edges, in its own coordinates. */\n  clip?: { left: number; right: number; width: number };\n  dispose: () => void;\n};\ntype Seen = { box: Box; layout: Box; font: number };\ntype Glow = {\n  el: HTMLElement;\n  x: MotionValue<number>;\n  y: MotionValue<number>;\n  w: MotionValue<number>;\n  h: MotionValue<number>;\n  o: MotionValue<number>;\n  target: Box | null;\n  dispose: () => void;\n};\n\nconst middle = (box: Box) => ({ x: box.left + box.width / 2, y: box.top + box.height / 2 });\nconst fontOf = (el: Element) => parseFloat(getComputedStyle(el).fontSize) || 16;\n\nfunction createEngine() {\n  const entries = new Map<string, Entry>();\n  const departed = new Map<string, Entry>();\n  let root: HTMLElement | null = null;\n  let layer: HTMLElement | null = null;\n  let glows: Glow[] = [];\n  let snap: { mode: Mode; field?: string; seen: Map<string, Seen> } | null = null;\n  let reduced = false;\n  let active: string | null = null;\n\n  const boxOf = (el: Element, base: DOMRect): Box => {\n    const r = el.getBoundingClientRect();\n    return { left: r.left - base.left, top: r.top - base.top, width: r.width, height: r.height };\n  };\n\n  const paint = (e: Entry) => {\n    const x = e.x.get();\n    const y = e.y.get();\n    const s = e.s.get();\n    e.el.style.transform = x || y || s !== 1 ? `translate(${x}px, ${y}px) scale(${s})` : \"\";\n    if (e.clip) {\n      const left = Math.max(0, e.clip.left - x);\n      const right = Math.max(0, e.clip.width - (e.clip.right - x));\n      e.el.style.clipPath = left > 0.5 || right > 0.5 ? `inset(0 ${right}px 0 ${left}px)` : \"\";\n    }\n  };\n\n  const unclip = (e: Entry) => {\n    if (!e.clip) return;\n    e.clip = undefined;\n    e.el.style.clipPath = \"\";\n  };\n\n  /** The element's box as laid out, without its own offset. */\n  const laidOut = (e: Entry, base: DOMRect): Box => {\n    const box = boxOf(e.el, base);\n    const s = e.s.get() || 1;\n    const c = middle(box);\n    const width = box.width / s;\n    const height = box.height / s;\n    return { left: c.x - e.x.get() - width / 2, top: c.y - e.y.get() - height / 2, width, height };\n  };\n\n  function flush() {\n    for (const e of departed.values()) e.dispose();\n    departed.clear();\n  }\n\n  function attach(id: string, el: HTMLElement) {\n    const old = departed.get(id);\n    if (old) departed.delete(id);\n    const detach = (entry: Entry) => () => {\n      if (entries.get(id) !== entry) return;\n      entries.delete(id);\n      departed.set(id, entry);\n    };\n    if (old?.el === el) {\n      // The same element came straight back (Strict Mode remounts effects): keep it moving.\n      entries.set(id, old);\n      return detach(old);\n    }\n    old?.dispose();\n    const x = motionValue(0);\n    const y = motionValue(0);\n    const s = motionValue(1);\n    const entry: Entry = { id, el, x, y, s, kind: \"word\", enter: \"none\", exit: \"none\", dispose: () => {} };\n    const draw = () => paint(entry);\n    const off = [x.on(\"change\", draw), y.on(\"change\", draw), s.on(\"change\", draw)];\n    entry.dispose = () => {\n      x.stop();\n      y.stop();\n      s.stop();\n      off.forEach((stop) => stop());\n    };\n    entries.set(id, entry);\n    return detach(entry);\n  }\n\n  function configure(id: string, options: PieceOptions) {\n    const entry = entries.get(id);\n    if (entry) Object.assign(entry, options);\n  }\n\n  /** Call right before a change that moves things: records where everything is on screen. */\n  function snapshot(mode: Mode, field?: string) {\n    if (!root) return;\n    if (snap) {\n      if (mode === \"flow\") snap.mode = \"flow\";\n      return;\n    }\n    flush();\n    const base = root.getBoundingClientRect();\n    const seen = new Map<string, Seen>();\n    for (const e of entries.values()) {\n      seen.set(e.id, {\n        box: boxOf(e.el, base),\n        layout: laidOut(e, base),\n        font: e.kind === \"chip\" || e.partner ? fontOf(e.el) : 0,\n      });\n    }\n    const mine = { mode, field, seen };\n    snap = mine;\n    // A change that never reaches a commit doesn't leave its snapshot behind.\n    requestAnimationFrame(() =>\n      requestAnimationFrame(() => {\n        if (snap === mine) snap = null;\n      }),\n    );\n  }\n\n  function move(mv: MotionValue<number>, from: number) {\n    if (Math.abs(from - mv.get()) < 0.5 && mv.isAnimating()) return; // already on its way there\n    if (Math.abs(from) < 0.5) {\n      mv.jump(0);\n      return;\n    }\n    const velocity = mv.getVelocity();\n    mv.jump(from);\n    animate(mv, 0, { ...SENTENCE.flow, velocity });\n  }\n\n  function glide(e: Entry, dx: number, dy: number) {\n    unclip(e);\n    if (reduced) {\n      e.x.jump(0);\n      e.y.jump(0);\n      return;\n    }\n    move(e.x, dx);\n    move(e.y, dy);\n  }\n\n  function fadeIn(e: Entry, delay = 0) {\n    // Explicit keyframes: motion remembers an element's last opacity and would skip a 1 → 1 \"fade\".\n    e.el.style.opacity = \"0\";\n    animate(e.el, { opacity: [0, 1] }, { ...SENTENCE.fade, delay });\n  }\n\n  function fly(e: Entry, from: Seen, to: Box, carried: { x: number; y: number }) {\n    if (reduced) return fadeIn(e);\n    const font = fontOf(e.el);\n    const a = middle(from.box);\n    const b = middle(to);\n    e.x.jump(a.x - b.x - carried.x);\n    e.y.jump(a.y - b.y - carried.y);\n    e.s.jump(from.font && font ? from.font / font : 1);\n    e.el.style.zIndex = \"10\";\n    animate(e.x, 0, SENTENCE.flow);\n    animate(e.y, 0, SENTENCE.flow);\n    animate(e.s, 1, SENTENCE.flow).then(() => {\n      e.el.style.zIndex = \"\";\n    });\n    const line = e.el.querySelector<HTMLElement>(\"[data-underline]\");\n    if (line) animate(line, { opacity: [0, 1] }, { ...SENTENCE.fade, delay: SENTENCE.flow.visualDuration * 0.5 });\n  }\n\n  function rise(e: Entry, i: number) {\n    const delay = SENTENCE.settle + i * SENTENCE.stagger;\n    fadeIn(e, delay);\n    if (reduced) return;\n    e.y.jump(SENTENCE.rise * fontOf(e.el));\n    animate(e.y, 0, { ...SENTENCE.flow, delay });\n  }\n\n  function grow(e: Entry) {\n    const line = e.el.querySelector<HTMLElement>(\"[data-underline]\");\n    if (!line) return;\n    if (reduced) animate(line, { opacity: [0, 1] }, SENTENCE.fade);\n    else animate(line, { scaleX: [0, 1] }, SENTENCE.underline);\n  }\n\n  /** A stand-in for an element that's leaving, left where it was in the layer under the sentence. */\n  function cloneOf(e: Entry, was: Seen, blank = false) {\n    if (!root || !layer) return null;\n    const clone = e.el.cloneNode(true) as HTMLElement;\n    const sources = e.el.querySelectorAll(\"input\");\n    clone.querySelectorAll(\"input\").forEach((input, i) => {\n      input.value = blank ? \"\" : (sources[i]?.value ?? \"\");\n      if (blank) input.placeholder = \"\";\n    });\n    for (const node of [clone, ...clone.querySelectorAll(\"*\")]) {\n      for (const name of [\"id\", \"name\", \"data-piece\", \"data-part\", \"data-blank\"]) node.removeAttribute(name);\n    }\n    clone.inert = true;\n    clone.setAttribute(\"aria-hidden\", \"true\");\n    const base = root.getBoundingClientRect();\n    const at = layer.getBoundingClientRect();\n    Object.assign(clone.style, {\n      position: \"absolute\",\n      left: `${was.box.left + base.left - at.left}px`,\n      top: `${was.box.top + base.top - at.top}px`,\n      width: `${was.box.width}px`,\n      height: `${was.box.height}px`,\n      margin: \"0\",\n      transform: \"\",\n      zIndex: \"\",\n      opacity: \"\",\n      clipPath: \"\",\n    });\n    layer.append(clone);\n    return clone;\n  }\n\n  function ghost(e: Entry, was: Seen, how: Exit | \"underline\") {\n    const clone = cloneOf(e, was, how === \"underline\");\n    if (!clone) return;\n    const done = () => clone.remove();\n    const line = clone.querySelector<HTMLElement>(\"[data-underline]\");\n    if (how === \"underline\") {\n      clone.style.color = \"transparent\";\n      if (line && !reduced) animate(line, { scaleX: 0 }, SENTENCE.underline).then(done);\n      else animate(clone, { opacity: 0 }, SENTENCE.fade).then(done);\n    } else if (how === \"fold\" && !reduced) {\n      clone.style.transformOrigin = \"50% 100%\";\n      clone\n        .animate(\n          [\n            { transform: \"perspective(32em) rotateX(0deg)\", opacity: 1 },\n            { transform: \"perspective(32em) rotateX(-50deg)\", opacity: 0.25, offset: 0.55 },\n            { transform: \"perspective(32em) rotateX(-82deg)\", opacity: 0 },\n          ],\n          { duration: SENTENCE.fold, easing: \"cubic-bezier(0.4, 0, 0.9, 0.6)\", fill: \"forwards\" },\n        )\n        .finished.then(done, done);\n    } else {\n      animate(clone, { opacity: 0 }, SENTENCE.fade).then(done);\n    }\n  }\n\n  /** A word wrapping to another line: its old copy slides off the end of the line it was on... */\n  function slideOut(e: Entry, was: Seen, from: number, to: number, left: number, right: number, quick: boolean) {\n    const clone = cloneOf(e, was);\n    if (!clone) return;\n    const x = motionValue(from);\n    const l = left - was.box.left;\n    const r = right - was.box.left;\n    const draw = () => {\n      const v = x.get();\n      clone.style.transform = `translateX(${v}px)`;\n      clone.style.clipPath = `inset(0 ${Math.max(0, was.box.width - (r - v))}px 0 ${Math.max(0, l - v)}px)`;\n    };\n    x.on(\"change\", draw);\n    draw();\n    animate(x, to, quick ? SENTENCE.typed : SENTENCE.flow);\n    animate(clone, { opacity: 0 }, { duration: quick ? 0.14 : 0.32, ease: quick ? \"easeOut\" : \"easeIn\" }).then(() => {\n      x.stop();\n      clone.remove();\n    });\n  }\n\n  /** ...and the word itself slides in at the other end of its new line. */\n  function slideIn(e: Entry, from: number, clip: NonNullable<Entry[\"clip\"]>, quick: boolean) {\n    e.y.jump(0);\n    e.clip = clip;\n    e.x.jump(from);\n    animate(e.x, 0, quick ? SENTENCE.typed : SENTENCE.flow);\n    const faded = quick ? 0.3 : 0;\n    e.el.style.opacity = String(faded);\n    animate(e.el, { opacity: [faded, 1] }, { duration: quick ? 0.18 : 0.3, ease: \"easeOut\" });\n  }\n\n  function commit(next: string | null) {\n    active = next;\n    const s = snap;\n    snap = null;\n    if (!root) return flush();\n    if (!s) {\n      flush();\n      glow(false);\n      return;\n    }\n\n    const base = root.getBoundingClientRect();\n    const list = [...entries.values()].sort((a, b) =>\n      a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,\n    );\n    for (const e of list) e.el.style.transform = \"\";\n    const layout = new Map(list.map((e) => [e.id, boxOf(e.el, base)] as const));\n\n    // An answer that splits into words as the form settles (or words that join\n    // back into an answer) starts out laid along the old one, and from there\n    // moves like any other word.\n    const splits = new Set<string>();\n    const virtual = new Map<string, Seen>();\n    const along = new Map<string, number>();\n    for (const e of list) {\n      if (s.seen.has(e.id) || !e.origin || !departed.has(e.origin)) continue;\n      const from = s.seen.get(e.origin);\n      if (!from) continue;\n      splits.add(e.origin);\n      const to = layout.get(e.id)!;\n      const x = along.get(e.origin) ?? 0;\n      const box = {\n        left: from.box.left + x,\n        top: from.box.top + (from.box.height - to.height) / 2,\n        width: to.width,\n        height: to.height,\n      };\n      virtual.set(e.id, { box, layout: box, font: 0 });\n      along.set(e.origin, x + to.width + SENTENCE.space * fontOf(e.el));\n    }\n    const wasOf = (e: Entry) => s.seen.get(e.id) ?? virtual.get(e.id);\n\n    // An element inside another (an option label inside the tray) rides on its\n    // holder's offset, so only the rest of the way is its own.\n    const starts = new Map<string, { x: number; y: number }>();\n    const carriedBy = (e: Entry) => {\n      const holder = e.el.parentElement?.closest<HTMLElement>(\"[data-piece]\");\n      return (holder?.dataset.piece && starts.get(holder.dataset.piece)) || { x: 0, y: 0 };\n    };\n\n    // Lines move sideways as one piece. For each line: how far the first and\n    // last words that stay on it move, so a word wrapping off one line and\n    // onto another can travel in step with them.\n    const col = layer ? boxOf(layer, base) : { left: 0, top: 0, width: base.width, height: 0 };\n    const left = col.left;\n    const right = col.left + col.width;\n    const lines: { top: number; first: number; firstShift: number; last: number; lastShift: number }[] = [];\n    const lineAt = (top: number) => lines.find((line) => Math.abs(line.top - top) < 4);\n    const crossed = (was: Seen, to: Box) => Math.abs(to.top - was.layout.top) > to.height / 2;\n    for (const e of list) {\n      const was = wasOf(e);\n      const to = layout.get(e.id)!;\n      if (e.kind !== \"word\" || !was || crossed(was, to)) continue;\n      const shift = to.left - was.layout.left;\n      let line = lineAt(to.top);\n      if (!line) {\n        line = { top: to.top, first: Infinity, firstShift: 0, last: -Infinity, lastShift: 0 };\n        lines.push(line);\n      }\n      if (to.left < line.first) Object.assign(line, { first: to.left, firstShift: shift });\n      if (to.left > line.last) Object.assign(line, { last: to.left, lastShift: shift });\n    }\n\n    // While typing, words that stay on their line move with the text at once,\n    // so a new letter never covers the next word; only words that change\n    // lines travel. The blank being typed in just goes where it's needed.\n    const typing = s.mode === \"type\";\n    const typedUnits = typing\n      ? list.filter((e) => e.el.dataset.part === s.field).map((e) => e.el.closest(\"[data-unit]\") ?? e.el)\n      : [];\n    const withTyped = (e: Entry) => typedUnits.some((unit) => unit.contains(e.el));\n\n    // Words that wrap forward (down and back to the start) or back (up to the\n    // end of the line before) go off one end and come in at the other, instead\n    // of cutting across the lines in between. The ones leaving (or joining) a\n    // line move together, far enough for all of them to clear the column.\n    const wraps = new Map<string, boolean>();\n    const away = new Map<number, number>();\n    const enter = new Map<number, number>();\n    const rowOf = (rows: Map<number, number>, top: number) => {\n      for (const row of rows.keys()) if (Math.abs(row - top) < 4) return row;\n      return top;\n    };\n    for (const e of list) {\n      const was = wasOf(e);\n      const to = layout.get(e.id)!;\n      if (reduced || e.kind !== \"word\" || !was || !crossed(was, to) || e.s.get() !== 1) continue;\n      if (typing && withTyped(e)) continue;\n      const forward = to.top > was.layout.top;\n      if (forward !== to.left < was.layout.left) continue;\n      wraps.set(e.id, forward);\n      const out = rowOf(away, was.layout.top);\n      const off = forward ? right - was.box.left : left - was.box.left - was.box.width;\n      away.set(out, forward ? Math.max(away.get(out) ?? off, off) : Math.min(away.get(out) ?? off, off));\n      const row = rowOf(enter, to.top);\n      const gap = SENTENCE.space * fontOf(e.el);\n      const on = forward ? to.left - left + to.width + gap : right - to.left + gap;\n      enter.set(row, Math.max(enter.get(row) ?? on, on));\n    }\n\n    const wrap = (e: Entry, was: Seen, to: Box, forward: boolean) => {\n      const from = lineAt(was.layout.top);\n      const into = lineAt(to.top);\n      const pushed = (forward ? from?.lastShift : from?.firstShift) ?? 0;\n      const clip = { left: left - to.left, right: right - to.left, width: to.width };\n      if (typing) {\n        // Typing moves the rest at once, so a word that changes lines just hops\n        // a little into its new place, the way text reflows in an editor.\n        slideIn(e, SENTENCE.hop * fontOf(e.el) * (forward ? -1 : 1), clip, true);\n        return;\n      }\n      const off = away.get(rowOf(away, was.layout.top)) ?? 0;\n      slideOut(e, was, 0, forward ? Math.max(pushed, off) : Math.min(pushed, off), left, right, false);\n      const lead = (forward ? into?.firstShift : into?.lastShift) ?? 0;\n      const on = enter.get(rowOf(enter, to.top)) ?? 0;\n      slideIn(e, forward ? -Math.max(lead, on) : Math.max(-lead, on), clip, false);\n    };\n\n    const flown = new Set<string>();\n    const risers: Entry[] = [];\n    for (const e of list) {\n      const to = layout.get(e.id)!;\n      const carried = carriedBy(e);\n      const was = wasOf(e);\n      if (was) {\n        if (virtual.has(e.id)) grow(e);\n        if (wraps.has(e.id)) {\n          wrap(e, was, to, wraps.get(e.id)!);\n          starts.set(e.id, { x: 0, y: 0 });\n          continue;\n        }\n        if (typing && e.kind === \"word\") {\n          if (withTyped(e)) {\n            unclip(e);\n            e.x.jump(0);\n            e.y.jump(0);\n          } else if (e.clip) {\n            // Still sliding in from a wrap: keep its clip on the column as the line shifts.\n            e.clip = { left: left - to.left, right: right - to.left, width: to.width };\n          }\n          if (!crossed(was, to) || withTyped(e)) {\n            starts.set(e.id, { x: e.x.get(), y: e.y.get() });\n            continue;\n          }\n        }\n        // Left edges stay put as a blank changes width; a scaled flight lines up by its middle.\n        const scaled = e.s.get() !== 1;\n        const dx = (scaled ? middle(was.box).x - middle(to).x : was.box.left - to.left) - carried.x;\n        const dy = middle(was.box).y - middle(to).y - carried.y;\n        starts.set(e.id, { x: dx, y: dy });\n        glide(e, dx, dy);\n        continue;\n      }\n      const partner = e.partner && departed.has(e.partner) ? s.seen.get(e.partner) : undefined;\n      if (partner) {\n        flown.add(e.partner!);\n        fly(e, partner, to, carried);\n        continue;\n      }\n      if (e.enter === \"rise\") risers.push(e);\n      else if (e.enter === \"fade\") fadeIn(e, typing ? 0 : SENTENCE.settle);\n    }\n    risers.forEach(rise);\n\n    for (const [id, e] of departed) {\n      const was = s.seen.get(id);\n      if (was && !flown.has(id)) {\n        // An answer that became words keeps only its underline, retracting under them.\n        if (splits.has(id)) {\n          if (e.el.querySelector(\"[data-underline]\")) ghost(e, was, \"underline\");\n        } else if (e.exit !== \"none\") ghost(e, was, e.exit);\n      }\n      e.dispose();\n    }\n    departed.clear();\n    for (const e of list) paint(e);\n    glow(s.mode);\n  }\n\n  function aim(g: Glow, target: Box | null, mode: Mode | false) {\n    const previous = g.target;\n    if (\n      target &&\n      previous &&\n      Math.abs(target.left - previous.left) < 0.5 &&\n      Math.abs(target.top - previous.top) < 0.5 &&\n      Math.abs(target.width - previous.width) < 0.5 &&\n      Math.abs(target.height - previous.height) < 0.5\n    ) {\n      return;\n    }\n    g.target = target;\n    if (!target) {\n      animate(g.o, 0, SENTENCE.fade);\n      return;\n    }\n    const shown = previous !== null && g.o.get() > 0.05;\n    const values = [\n      [g.x, target.left],\n      [g.y, target.top],\n      [g.w, target.width],\n      [g.h, target.height],\n    ] as const;\n    if (!shown || mode === false || reduced) {\n      for (const [mv, to] of values) mv.jump(to);\n      if (g.o.get() < 1) animate(g.o, 1, SENTENCE.fade);\n      return;\n    }\n    // Typing moves the highlight with the text, not after it.\n    const along = mode === \"type\";\n    for (const [mv, to] of values) {\n      if (along) mv.jump(to);\n      else animate(mv, to, SENTENCE.flow);\n    }\n    animate(g.o, 1, SENTENCE.fade);\n  }\n\n  /** Points the highlight at the active blank: one box per line it's on. */\n  function glow(mode: Mode | false) {\n    if (!layer || !glows.length) return;\n    const at = layer.getBoundingClientRect();\n    const em = fontOf(layer);\n    const boxes = active\n      ? [...entries.values()]\n          .filter((e) => e.el.dataset.part === active)\n          .map((e) => laidOut(e, at))\n          .sort((a, b) => a.top - b.top || a.left - b.left)\n      : [];\n    const lines: Box[] = [];\n    for (const box of boxes) {\n      const line = lines[lines.length - 1];\n      if (line && Math.abs(middle(line).y - middle(box).y) < box.height / 2) {\n        const right = Math.max(line.left + line.width, box.left + box.width);\n        const bottom = Math.max(line.top + line.height, box.top + box.height);\n        line.left = Math.min(line.left, box.left);\n        line.top = Math.min(line.top, box.top);\n        line.width = right - line.left;\n        line.height = bottom - line.top;\n      } else lines.push({ ...box });\n    }\n    const padX = SENTENCE.glow.x * em;\n    const padY = SENTENCE.glow.y * em;\n    glows.forEach((g, i) => {\n      const line = lines[i];\n      aim(\n        g,\n        line\n          ? {\n              left: line.left - padX,\n              top: line.top - padY,\n              width: line.width + padX * 2,\n              height: line.height + padY * 2,\n            }\n          : null,\n        mode,\n      );\n    });\n  }\n\n  /** Layout changed on its own (a resize, a font that loaded late): everything takes its new place. */\n  function settle() {\n    for (const e of entries.values()) {\n      e.x.jump(0);\n      e.y.jump(0);\n      e.s.jump(1);\n      e.el.style.zIndex = \"\";\n      unclip(e);\n    }\n    glow(false);\n  }\n\n  function connect(form: HTMLElement, sentence: HTMLElement, overlay: HTMLElement) {\n    root = form;\n    layer = overlay;\n    glows = [...overlay.querySelectorAll<HTMLElement>(\"[data-glow]\")].map((el) => {\n      const g: Glow = {\n        el,\n        x: motionValue(0),\n        y: motionValue(0),\n        w: motionValue(0),\n        h: motionValue(0),\n        o: motionValue(0),\n        target: null,\n        dispose: () => {},\n      };\n      const draw = () => {\n        el.style.transform = `translate(${g.x.get()}px, ${g.y.get()}px)`;\n        el.style.width = `${g.w.get()}px`;\n        el.style.height = `${g.h.get()}px`;\n        el.style.opacity = String(g.o.get());\n      };\n      const off = [g.x, g.y, g.w, g.h, g.o].map((mv) => mv.on(\"change\", draw));\n      g.dispose = () => off.forEach((stop) => stop());\n      return g;\n    });\n    let width = sentence.getBoundingClientRect().width;\n    const observer = new ResizeObserver(() => {\n      const next = sentence.getBoundingClientRect().width;\n      if (Math.abs(next - width) < 0.5) return;\n      width = next;\n      settle();\n    });\n    observer.observe(sentence);\n    const fonts = document.fonts;\n    fonts?.addEventListener?.(\"loadingdone\", settle);\n    return () => {\n      observer.disconnect();\n      fonts?.removeEventListener?.(\"loadingdone\", settle);\n      glows.forEach((g) => g.dispose());\n      glows = [];\n      root = null;\n      layer = null;\n    };\n  }\n\n  function shake(name: string) {\n    if (reduced) return;\n    for (const e of entries.values()) {\n      if (e.el.dataset.part === name) animate(e.x, [...SENTENCE.shake], { duration: 0.42, ease: \"easeOut\" });\n    }\n  }\n\n  return {\n    attach,\n    configure,\n    snapshot,\n    commit,\n    connect,\n    shake,\n    setReduced: (value: boolean) => {\n      reduced = value;\n    },\n  };\n}\n\ntype Engine = ReturnType<typeof createEngine>;\n\nconst EngineContext = React.createContext<Engine | null>(null);\n\ntype PieceProps = Partial<PieceOptions> &\n  React.HTMLAttributes<HTMLElement> & {\n    id: string;\n    /** The field this is a visible part of; the highlight wraps a field's parts. */\n    part?: string;\n    as?: \"span\" | \"div\";\n  };\n\n/** One element the engine moves. Words, answers and option labels are inline blocks. */\nfunction Piece({\n  id,\n  part,\n  kind = \"word\",\n  partner,\n  origin,\n  enter = \"none\",\n  exit = \"none\",\n  as = \"span\",\n  className,\n  ...props\n}: PieceProps) {\n  const engine = React.useContext(EngineContext);\n  const ref = React.useRef<HTMLElement>(null);\n  React.useLayoutEffect(() => {\n    if (engine && ref.current) return engine.attach(id, ref.current);\n  }, [engine, id]);\n  React.useLayoutEffect(() => {\n    engine?.configure(id, { kind, partner, origin, enter, exit });\n  });\n  const shared = {\n    ...props,\n    \"data-piece\": id,\n    \"data-part\": part,\n    className: cn(\"relative\", kind !== \"block\" && \"inline-block\", className),\n  };\n  return as === \"div\" ? (\n    <div ref={ref as React.Ref<HTMLDivElement>} {...shared} />\n  ) : (\n    <span ref={ref as React.Ref<HTMLSpanElement>} {...shared} />\n  );\n}\n\n/* ── Form state shared with the blanks and the tray ─────── */\n\ntype Ids = { options: string; label: string; hint: string; error: string };\n\ntype Api = {\n  locale: string;\n  status: Status;\n  editable: boolean;\n  reduced: boolean;\n  active: string | null;\n  keys: boolean;\n  errors: Record<string, FieldError>;\n  ids: (name: string) => Ids;\n  valueOf: (name: string) => string | string[];\n  typing: (field: Field) => boolean;\n  upNext: (name: string) => string | undefined;\n  describedBy: (field: Field) => string | undefined;\n  choice: (field: Field) => React.HTMLAttributes<HTMLElement>;\n  text: (field: Field) => React.InputHTMLAttributes<HTMLInputElement>;\n  pick: (field: Field, value: string) => void;\n  other: (field: Field) => void;\n  optionsKeyDown: (field: Field, event: React.KeyboardEvent<HTMLElement>) => void;\n};\n\nconst FormContext = React.createContext<Api | null>(null);\n\nfunction useForm() {\n  const form = React.useContext(FormContext);\n  if (!form) throw new Error(\"This part must be rendered inside <SentenceForm>.\");\n  return form;\n}\n\nconst hintFor = (field: Field) =>\n  field.hint ??\n  (field.options\n    ? field.multiple\n      ? \"Pick any\"\n      : field.custom\n        ? \"Pick one, or write your own\"\n        : \"Pick one\"\n    : null);\n\nconst otherLabel = (field: Field) => (typeof field.custom === \"string\" ? field.custom : \"Other\");\n\n/* ── SentenceForm ───────────────────────────────────────── */\n\ntype Focus = { blank: string; shake?: boolean } | { options: string } | { success: true };\n\nexport interface SentenceFormProps\n  extends Omit<React.FormHTMLAttributes<HTMLFormElement>, \"onSubmit\" | \"children\"> {\n  /**\n   * The sentence, with `SentenceFormField`s as its blanks. Or a function of\n   * the current values, for clauses that depend on an answer.\n   */\n  children: React.ReactNode | ((values: SentenceFormValues) => React.ReactNode);\n  /**\n   * Called on send with the values of the blanks in the sentence and the\n   * form's FormData. Return a promise to hold the form in a pending state;\n   * if it rejects, `errorMessage` shows and nothing is lost.\n   */\n  onSubmit?: (values: SentenceFormValues, formData: FormData) => void | PromiseLike<unknown>;\n  /** The confirmation that rises where the button was, or a function of what was sent. */\n  success?:\n    | React.ReactNode\n    | ((sent: { values: SentenceFormValues; edit: () => void; reset: () => void }) => React.ReactNode);\n  /** Shown, and announced, when `onSubmit` throws or its promise rejects. */\n  errorMessage?: React.ReactNode;\n  /** The send button's label. */\n  submitLabel?: React.ReactNode;\n  /** The values, to control the form from outside. Pair with `onValuesChange`. */\n  values?: SentenceFormValues;\n  /** The values it starts with. A field's own `defaultValue` fills in the rest. */\n  defaultValues?: SentenceFormValues;\n  /** Called with every value whenever one changes. */\n  onValuesChange?: (values: SentenceFormValues) => void;\n  /** Locale for joining picks (\"a, b, and c\"). Set explicitly so the server and browser agree. */\n  locale?: string;\n  /** Classes for the tray under the sentence. */\n  trayClassName?: string;\n  /** Classes for the send button. It has `data-ready=\"true\"` once the required blanks are filled. */\n  submitClassName?: string;\n}\n\n/**\n * A form written as a sentence. Each blank is a real field set inline at the\n * sentence's own size; picked options fly up into their blanks while the\n * words after them glide aside, and on send the form settles into the\n * sentence that was written.\n */\nexport function SentenceForm({\n  children,\n  onSubmit,\n  success,\n  errorMessage = \"That didn't go through. Everything you wrote is still here, so try again.\",\n  submitLabel = \"Send\",\n  values,\n  defaultValues,\n  onValuesChange,\n  locale = \"en-US\",\n  className,\n  trayClassName,\n  submitClassName,\n  onBlur,\n  ...props\n}: SentenceFormProps) {\n  const uid = React.useId();\n  const reduced = useReducedMotion() ?? false;\n  const [engine] = React.useState(createEngine);\n  const [own, setOwn] = React.useState<SentenceFormValues>(() => defaultValues ?? {});\n  const [active, setActive] = React.useState<string | null>(null);\n  const [custom, setCustom] = React.useState<Record<string, boolean>>({});\n  const [errors, setErrors] = React.useState<Record<string, FieldError>>({});\n  const [status, setStatus] = React.useState<Status>(\"editing\");\n  const [sent, setSent] = React.useState<SentenceFormValues>({});\n  const [keys, setKeys] = React.useState(false);\n  const [news, setNews] = React.useState<{ id: number; text: React.ReactNode }>({ id: 0, text: null });\n  const [restarts, setRestarts] = React.useState(0);\n\n  const formRef = React.useRef<HTMLFormElement>(null);\n  const sentenceRef = React.useRef<HTMLDivElement>(null);\n  const layerRef = React.useRef<HTMLSpanElement>(null);\n  const submitRef = React.useRef<HTMLButtonElement>(null);\n  const successRef = React.useRef<HTMLDivElement>(null);\n  const focusNext = React.useRef<Focus | null>(null);\n  const beat = React.useRef(0);\n  const ticket = React.useRef(0);\n  const modality = React.useRef<\"keys\" | \"pointer\">(\"pointer\");\n\n  /* The sentence, read with the current values. */\n  const raw = values ?? own;\n  const read = (source: SentenceFormValues) =>\n    typeof children === \"function\" ? children(source) : children;\n  let parsed = parse(read(raw));\n  let current = raw;\n  for (const field of parsed.fields) {\n    if (field.defaultValue !== undefined && current[field.name] === undefined) {\n      current = { ...current, [field.name]: field.defaultValue };\n    }\n  }\n  if (current !== raw && typeof children === \"function\") parsed = parse(read(current));\n  const { flow, fields } = parsed;\n  const byName = new Map(fields.map((field) => [field.name, field]));\n\n  const valueOf = (name: string): string | string[] => {\n    const value = current[name];\n    if (byName.get(name)?.multiple) return Array.isArray(value) ? value : value ? [value] : [];\n    return Array.isArray(value) ? (value[0] ?? \"\") : (value ?? \"\");\n  };\n  /** Whether a blank with options is showing its \"Other\" text input. */\n  const typing = (field: Field) => {\n    if (!field.options) return true;\n    if (!field.custom || field.multiple) return false;\n    const value = valueOf(field.name) as string;\n    return !!custom[field.name] || (!!value && !field.options.some((option) => option.value === value));\n  };\n\n  const editable = status === \"editing\" || status === \"failed\";\n  const ready = fields.every((field) => !field.required || !isEmpty(valueOf(field.name)));\n\n  const latest = React.useRef({ fields, valueOf, active });\n  React.useLayoutEffect(() => {\n    latest.current = { fields, valueOf, active };\n  });\n\n  const ids = (name: string): Ids => {\n    const base = `${uid}-${name.replace(/[^\\w-]/g, \"_\")}`;\n    return { options: `${base}-options`, label: `${base}-label`, hint: `${base}-hint`, error: `${base}-error` };\n  };\n  const blankOf = (name: string) =>\n    formRef.current?.querySelector<HTMLElement>(`[data-blank=\"${CSS.escape(name)}\"]`) ?? null;\n  const trayOf = () => formRef.current?.querySelector<HTMLElement>(\"[data-tray]\") ?? null;\n  const say = (text: React.ReactNode) => setNews((n) => ({ id: n.id + 1, text }));\n\n  /* Moving between blanks. */\n\n  const open = (name: string | null) => {\n    if (name === active) return;\n    engine.snapshot(\"flow\");\n    setActive(name);\n    if (name) setKeys(modality.current === \"keys\");\n  };\n\n  const focusOptions = (name: string) => {\n    const list = document.getElementById(ids(name).options);\n    const target =\n      list?.querySelector<HTMLElement>('[aria-selected=\"true\"]') ??\n      list?.querySelector<HTMLElement>('[role=\"option\"]');\n    target?.focus();\n  };\n\n  const intoOptions = (name: string) => {\n    setKeys(true);\n    if (active === name) focusOptions(name);\n    else {\n      open(name);\n      focusNext.current = { options: name };\n    }\n  };\n\n  const upNext = (from: string) => nextEmpty(from, fields, valueOf);\n\n  const advance = (from: string, sendIfDone: boolean) => {\n    const next = nextEmpty(from, latest.current.fields, latest.current.valueOf);\n    if (next) blankOf(next)?.focus();\n    else if (sendIfDone) formRef.current?.requestSubmit(submitRef.current);\n    else submitRef.current?.focus();\n  };\n\n  /** Tab out of a tray: the blank after its own, or the send button. */\n  const after = (name: string) => {\n    const i = fields.findIndex((field) => field.name === name);\n    const next = fields[i + 1];\n    if (next) blankOf(next.name)?.focus();\n    else submitRef.current?.focus();\n  };\n\n  /* Changing values. */\n\n  const write = (name: string, next: string | string[], mode: Mode = \"flow\") => {\n    engine.snapshot(mode, name);\n    const all = { ...current, [name]: next };\n    if (values === undefined) setOwn(all);\n    onValuesChange?.(all);\n    const error = errors[name];\n    const field = byName.get(name);\n    if (error && field) {\n      const fixed =\n        error.kind === \"required\" ? !isEmpty(next) : isEmpty(next) || !check(field, next, typing(field));\n      if (fixed) setErrors((all) => Object.fromEntries(Object.entries(all).filter(([key]) => key !== name)));\n    }\n    if (status === \"failed\") setStatus(\"editing\");\n  };\n\n  const pick = (field: Field, value: string) => {\n    if (!editable) return;\n    window.clearTimeout(beat.current);\n    const now = valueOf(field.name);\n    if (field.multiple) {\n      const picks = now as string[];\n      write(field.name, picks.includes(value) ? picks.filter((v) => v !== value) : [...picks, value]);\n      return;\n    }\n    const wasTyping = typing(field);\n    if (wasTyping) {\n      // The text input goes away, so focus moves to the blank it turns back into.\n      setCustom((all) => ({ ...all, [field.name]: false }));\n      focusNext.current = { blank: field.name };\n    }\n    if (now === value && !wasTyping) {\n      write(field.name, \"\"); // the outline was tapped: send the pick back down\n      return;\n    }\n    write(field.name, value);\n    beat.current = window.setTimeout(() => {\n      if (latest.current.active === field.name) advance(field.name, false);\n    }, SENTENCE.beat);\n  };\n\n  const other = (field: Field) => {\n    if (!editable) return;\n    window.clearTimeout(beat.current);\n    if (typing(field)) {\n      blankOf(field.name)?.focus();\n      return;\n    }\n    engine.snapshot(\"flow\");\n    setCustom((all) => ({ ...all, [field.name]: true }));\n    if (valueOf(field.name)) write(field.name, \"\"); // the old pick drops back into the tray\n    focusNext.current = { blank: field.name };\n  };\n\n  const closeCustom = (field: Field, refocus: boolean) => {\n    engine.snapshot(\"flow\");\n    setCustom((all) => ({ ...all, [field.name]: false }));\n    if (refocus) focusNext.current = { blank: field.name };\n  };\n\n  /* Event handlers for the blanks and the tray. */\n\n  const choice = (field: Field): React.HTMLAttributes<HTMLElement> => ({\n    onFocus: () => {\n      if (editable) open(field.name);\n    },\n    // Focus is moved by hand on click, so a click never blurs the blank you're\n    // leaving before the new one opens (Safari doesn't focus buttons on click).\n    onMouseDown: (event) => {\n      if (event.button === 0) event.preventDefault();\n    },\n    onClick: (event) => {\n      if (!editable || event.detail === 0) return;\n      // Focusing opens it; clicking it again keeps it open (Esc or a click elsewhere closes).\n      if (document.activeElement !== event.currentTarget) event.currentTarget.focus();\n      else open(field.name);\n    },\n    onKeyDown: (event) => {\n      if (!editable) return;\n      if (event.key === \"Enter\" || event.key === \" \" || event.key === \"ArrowDown\") {\n        event.preventDefault();\n        intoOptions(field.name);\n      } else if (event.key === \"Escape\" && active === field.name) {\n        event.preventDefault();\n        open(null);\n      }\n    },\n  });\n\n  const text = (field: Field): React.InputHTMLAttributes<HTMLInputElement> => {\n    const isCustom = !!field.options;\n    return {\n      onFocus: () => {\n        if (editable) open(field.name);\n      },\n      onChange: (event) => write(field.name, event.target.value, \"type\"),\n      onKeyDown: (event) => {\n        if (!editable) return;\n        if (event.key === \"Enter\" && !event.nativeEvent.isComposing) {\n          event.preventDefault();\n          advance(field.name, true);\n        } else if (event.key === \"ArrowDown\" && isCustom) {\n          event.preventDefault();\n          intoOptions(field.name);\n        } else if (event.key === \"Escape\") {\n          if (isCustom && isEmpty(valueOf(field.name))) {\n            event.preventDefault();\n            closeCustom(field, true);\n          } else if (active === field.name) {\n            event.preventDefault();\n            open(null);\n          }\n        }\n      },\n      onBlur: (event) => {\n        if (!isCustom || !isEmpty(valueOf(field.name)) || !event.target.isConnected) return;\n        if (event.relatedTarget && trayOf()?.contains(event.relatedTarget as Node)) return;\n        closeCustom(field, false);\n      },\n    };\n  };\n\n  const optionsKeyDown = (field: Field, event: React.KeyboardEvent<HTMLElement>) => {\n    const items = [...event.currentTarget.querySelectorAll<HTMLElement>('[role=\"option\"]')];\n    const i = items.indexOf(document.activeElement as HTMLElement);\n    const go = (j: number) => {\n      event.preventDefault();\n      items[Math.max(0, Math.min(items.length - 1, j))]?.focus();\n    };\n    const toBlank = () => {\n      event.preventDefault();\n      blankOf(field.name)?.focus();\n    };\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        return go(i + 1);\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        return i <= 0 ? toBlank() : go(i - 1);\n      case \"Home\":\n        return go(0);\n      case \"End\":\n        return go(items.length - 1);\n      case \"Escape\":\n        return toBlank();\n      case \"Tab\":\n        if (event.shiftKey) return toBlank();\n        event.preventDefault();\n        return after(field.name);\n      case \" \":\n      case \"Enter\": {\n        event.preventDefault();\n        const option = items[i];\n        if (!option) return;\n        const value = option.dataset.option;\n        if (field.multiple && event.key === \"Enter\") {\n          // Enter finishes a pick-any blank, picking the focused option if nothing is picked yet.\n          if (value !== undefined && isEmpty(valueOf(field.name))) {\n            pick(field, value);\n            beat.current = window.setTimeout(() => advance(field.name, false), SENTENCE.beat);\n          } else advance(field.name, false);\n          return;\n        }\n        if (value === undefined) other(field);\n        else pick(field, value);\n      }\n    }\n  };\n\n  const describedBy = (field: Field) => {\n    if (active !== field.name) return undefined;\n    const { hint, error } = ids(field.name);\n    return [hintFor(field) ? hint : null, errors[field.name] ? error : null].filter(Boolean).join(\" \") || undefined;\n  };\n\n  /* Sending. */\n\n  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    if (!editable) return;\n    window.clearTimeout(beat.current);\n    const problems = fields.flatMap((field) => {\n      const problem = check(field, valueOf(field.name), typing(field));\n      return problem ? [{ field, problem }] : [];\n    });\n    if (problems.length) {\n      engine.snapshot(\"flow\");\n      setErrors(Object.fromEntries(problems.map(({ field, problem }) => [field.name, problem])));\n      if (status === \"failed\") setStatus(\"editing\");\n      const [{ field, problem }] = problems;\n      focusNext.current = { blank: field.name, shake: true };\n      say(`${field.label}: ${problem.message}`);\n      return;\n    }\n\n    // Read the form before anything is locked: disabled fields drop out of FormData.\n    const data = new FormData(event.currentTarget);\n    const submitted = Object.fromEntries(fields.map((field) => [field.name, valueOf(field.name)]));\n    const mine = ++ticket.current;\n    const done = () => {\n      if (mine !== ticket.current) return;\n      engine.snapshot(\"flow\");\n      setSent(submitted);\n      setErrors({});\n      say(null); // the confirmation takes focus and speaks for itself\n      setActive(null);\n      setStatus(\"sent\");\n      focusNext.current = { success: true };\n    };\n    const fail = () => {\n      if (mine !== ticket.current) return;\n      setStatus(\"failed\");\n      say(errorMessage);\n    };\n    let result: void | PromiseLike<unknown>;\n    try {\n      result = onSubmit?.(submitted, data);\n    } catch {\n      fail();\n      return;\n    }\n    if (result && typeof result.then === \"function\") {\n      submitRef.current?.focus(); // the blanks lock while it sends\n      engine.snapshot(\"flow\");\n      setActive(null);\n      setStatus(\"pending\");\n      result.then(done, fail);\n    } else done();\n  };\n\n  // Both reopen the sentence and focus its first blank (see the effect below).\n  const edit = () => {\n    engine.snapshot(\"flow\");\n    setStatus(\"editing\");\n    setRestarts((n) => n + 1);\n  };\n\n  const reset = () => {\n    engine.snapshot(\"flow\");\n    const start = defaultValues ?? {};\n    if (values === undefined) setOwn(start);\n    onValuesChange?.(start);\n    setCustom({});\n    setErrors({});\n    setStatus(\"editing\");\n    setRestarts((n) => n + 1);\n  };\n\n  const handleBlur = (event: React.FocusEvent<HTMLFormElement>) => {\n    onBlur?.(event);\n    if (!event.target.isConnected) return; // its blank was swapped for another, which takes focus\n    const to = event.relatedTarget;\n    if (to && (sentenceRef.current?.contains(to) || trayOf()?.contains(to))) return;\n    if (active) open(null);\n  };\n\n  /* Effects. */\n\n  React.useLayoutEffect(\n    () => engine.connect(formRef.current!, sentenceRef.current!, layerRef.current!),\n    [engine],\n  );\n  React.useLayoutEffect(() => engine.setReduced(reduced), [engine, reduced]);\n\n  React.useLayoutEffect(() => {\n    engine.commit(editable ? active : null);\n    const next = focusNext.current;\n    if (!next) return;\n    focusNext.current = null;\n    if (\"success\" in next) successRef.current?.focus();\n    else if (\"options\" in next) focusOptions(next.options);\n    else {\n      blankOf(next.blank)?.focus();\n      if (next.shake) requestAnimationFrame(() => engine.shake(next.blank));\n    }\n  });\n\n  React.useLayoutEffect(() => {\n    if (restarts) formRef.current?.querySelector<HTMLElement>(\"[data-blank]\")?.focus();\n  }, [restarts]);\n\n  React.useEffect(() => {\n    const onKey = (event: KeyboardEvent) => {\n      if (/^(Tab|Enter| |Arrow)/.test(event.key)) modality.current = \"keys\";\n    };\n    const onPointer = () => {\n      modality.current = \"pointer\";\n    };\n    window.addEventListener(\"keydown\", onKey, true);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey, true);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n      window.clearTimeout(beat.current);\n    };\n  }, []);\n\n  // On a phone, keep the active blank and its tray above the keyboard.\n  React.useEffect(() => {\n    const form = formRef.current;\n    if (!active || !form) return;\n    const view = window.visualViewport;\n    const keep = () => {\n      const blank = form.querySelector(`[data-blank=\"${CSS.escape(active)}\"]`);\n      const tray = form.querySelector(\"[data-tray]\");\n      if (!blank || !tray) return;\n      const top = view ? view.offsetTop : 0;\n      const bottom = top + (view ? view.height : window.innerHeight);\n      const over = tray.getBoundingClientRect().bottom + 16 - bottom;\n      const room = blank.getBoundingClientRect().top - top - 16;\n      if (over > 0 && room > 0) window.scrollBy({ top: Math.min(over, room) });\n    };\n    const frame = requestAnimationFrame(keep);\n    view?.addEventListener(\"resize\", keep);\n    return () => {\n      cancelAnimationFrame(frame);\n      view?.removeEventListener(\"resize\", keep);\n    };\n  }, [active]);\n\n  /* Rendering. */\n\n  const settled = status === \"sent\";\n  const spoken = (field: Field) => {\n    const value = valueOf(field.name);\n    if (isEmpty(value)) return \"\";\n    if (Array.isArray(value)) return listFormat(locale).format(value.map((v) => labelOf(field, v)));\n    return typing(field) ? value : labelOf(field, value);\n  };\n  const { before, after: lastWords } = speech(flow, settled ? spoken : () => null);\n\n  const api: Api = {\n    locale,\n    status,\n    editable,\n    reduced,\n    active,\n    keys,\n    errors,\n    ids,\n    valueOf,\n    typing,\n    upNext,\n    describedBy,\n    choice,\n    text,\n    pick,\n    other,\n    optionsKeyDown,\n  };\n\n  const activeField = editable && active ? byName.get(active) : undefined;\n  const pending = status === \"pending\";\n  const confirmation =\n    typeof success === \"function\" ? success({ values: sent, edit, reset }) : (success ?? \"Sent. Thank you!\");\n\n  return (\n    <EngineContext.Provider value={engine}>\n      <FormContext.Provider value={api}>\n        <form\n          {...props}\n          ref={formRef}\n          noValidate\n          aria-busy={pending || undefined}\n          onSubmit={handleSubmit}\n          onBlur={handleBlur}\n          className={cn(\n            \"relative isolate [--sentence-error:color-mix(in_oklab,#e5484d_78%,currentColor)]\",\n            className,\n          )}\n        >\n          <div ref={sentenceRef} className=\"relative\">\n            <span ref={layerRef} aria-hidden className=\"pointer-events-none absolute inset-0\">\n              {[0, 1, 2].map((i) => (\n                <span\n                  key={i}\n                  data-glow=\"\"\n                  className=\"absolute top-0 left-0 rounded-[0.2em] bg-current/[0.07] opacity-0\"\n                />\n              ))}\n            </span>\n            {group(flow).map((unit, i) => (\n              <React.Fragment key={unit.key}>\n                {i > 0 ? \" \" : null}\n                {unit.atoms.map((atom) =>\n                  before.get(atom.id) ? (\n                    <span key={`said-${atom.id}`} className=\"sr-only\">\n                      {before.get(atom.id)}\n                    </span>\n                  ) : null,\n                )}\n                <Unit atoms={unit.atoms} />\n              </React.Fragment>\n            ))}\n            {lastWords ? <span className=\"sr-only\">{lastWords}</span> : null}\n          </div>\n\n          <Piece id=\"tray\" kind=\"block\" as=\"div\" data-tray=\"\" className={cn(\"mt-7\", trayClassName)}>\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {activeField ? <Tray key={activeField.name} field={activeField} /> : null}\n            </AnimatePresence>\n          </Piece>\n\n          <Piece id=\"actions\" kind=\"block\" as=\"div\" className=\"mt-7 font-sans text-[15px]\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {settled ? (\n                <motion.div\n                  key=\"sent\"\n                  ref={successRef}\n                  tabIndex={-1}\n                  className=\"outline-none\"\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, y: 14 }}\n                  animate={{ opacity: 1, y: 0, transition: { ...SENTENCE.flow, delay: 0.12 } }}\n                  exit={{ opacity: 0, y: reduced ? 0 : 6, transition: { duration: 0.15 } }}\n                >\n                  {confirmation}\n                </motion.div>\n              ) : (\n                <motion.div\n                  key=\"send\"\n                  className=\"flex flex-wrap items-center gap-x-4 gap-y-2\"\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8 }}\n                  animate={{ opacity: 1, y: 0, transition: { ...SENTENCE.flow, delay: 0.12 } }}\n                  exit={{ opacity: 0, y: reduced ? 0 : -8, transition: { duration: 0.15 } }}\n                >\n                  <button\n                    ref={submitRef}\n                    type=\"submit\"\n                    data-ready={ready}\n                    aria-disabled={pending || undefined}\n                    className={cn(\n                      \"group/send inline-flex h-11 cursor-pointer items-center gap-2 rounded-full px-5 font-medium ring-1 ring-current/25 outline-none ring-inset select-none\",\n                      \"transition-[background-color,color,box-shadow,scale] duration-200 ease-out hover:ring-current/50 active:scale-[0.97] motion-reduce:active:scale-100\",\n                      \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\",\n                      \"data-[ready=true]:bg-foreground data-[ready=true]:text-background data-[ready=true]:ring-transparent\",\n                      \"aria-disabled:cursor-progress\",\n                      submitClassName,\n                    )}\n                  >\n                    {submitLabel}\n                    {pending ? (\n                      <span\n                        aria-hidden\n                        className=\"size-3.5 animate-spin rounded-full border-2 border-current border-r-transparent motion-reduce:animate-none\"\n                      />\n                    ) : (\n                      <svg\n                        aria-hidden\n                        viewBox=\"0 0 16 16\"\n                        className=\"size-3.5 transition-transform duration-200 ease-out group-hover/send:translate-x-0.5\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"1.75\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                      >\n                        <path d=\"M3 8h10M9 4l4 4-4 4\" />\n                      </svg>\n                    )}\n                    {pending ? <span className=\"sr-only\">, sending</span> : null}\n                  </button>\n                  <AnimatePresence initial={false}>\n                    {status === \"failed\" ? (\n                      <motion.p\n                        className=\"text-(--sentence-error)\"\n                        initial={reduced ? { opacity: 0 } : { opacity: 0, x: -6 }}\n                        animate={{ opacity: 1, x: 0, transition: SENTENCE.flow }}\n                        exit={{ opacity: 0, transition: { duration: 0.15 } }}\n                      >\n                        {errorMessage}\n                      </motion.p>\n                    ) : null}\n                  </AnimatePresence>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </Piece>\n\n          <span role=\"status\" className=\"sr-only\">\n            <span key={news.id}>{news.text}</span>\n          </span>\n        </form>\n      </FormContext.Provider>\n    </EngineContext.Provider>\n  );\n}\n\n/* ── The sentence's pieces ──────────────────────────────── */\n\n/** Words and elements, each its own piece. */\nfunction Glue({ atoms }: { atoms: Atom[] }) {\n  return atoms.map((atom) =>\n    atom.kind === \"field\" ? null : (\n      <Piece key={atom.id} id={atom.id} aria-hidden={atom.kind === \"text\" || undefined} enter=\"rise\" exit=\"fold\">\n        {atom.kind === \"text\" ? atom.text : atom.node}\n      </Piece>\n    ),\n  );\n}\n\n/** A run with no spaces in it: words glued together, or a blank with the punctuation that touches it. */\nfunction Unit({ atoms }: { atoms: Atom[] }) {\n  const blanks: BlankProps[] = [];\n  const lead: Atom[] = [];\n  for (const atom of atoms) {\n    if (atom.kind === \"field\") blanks.push({ field: atom.field, prefix: blanks.length ? [] : lead, suffix: [] });\n    else if (blanks.length) blanks[blanks.length - 1].suffix.push(atom);\n    else lead.push(atom);\n  }\n  if (!blanks.length) {\n    return atoms.length > 1 ? (\n      <span className=\"whitespace-nowrap\">\n        <Glue atoms={atoms} />\n      </span>\n    ) : (\n      <Glue atoms={atoms} />\n    );\n  }\n  return blanks.map((blank) => <Blank key={blank.field.name} {...blank} />);\n}\n\ntype BlankProps = { field: Field; prefix: Atom[]; suffix: Atom[] };\n\nfunction Blank(props: BlankProps) {\n  const form = useForm();\n  const { field } = props;\n  if (form.status === \"sent\") return <SettledBlank {...props} />;\n  if (form.typing(field)) return <TextBlank {...props} />;\n  return field.multiple ? <ManyBlank {...props} /> : <OneBlank {...props} />;\n}\n\nfunction Underline({ filled, active, invalid }: { filled: boolean; active: boolean; invalid: boolean }) {\n  return (\n    <span\n      aria-hidden\n      data-underline=\"\"\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 bottom-[0.04em] origin-left border-b-[max(1.5px,0.05em)] transition-[border-color,border-bottom-width] duration-200\",\n        filled ? \"border-solid\" : \"border-dotted\",\n        invalid\n          ? \"border-(--sentence-error)\"\n          : active\n            ? \"border-current/80\"\n            : filled\n              ? \"border-current/25\"\n              : \"border-current/40\",\n        active && \"border-b-[max(2.5px,0.085em)]\",\n      )}\n    />\n  );\n}\n\n/** One answer, or the placeholder, sitting on its underline. */\nfunction Answer({\n  id,\n  field,\n  partner,\n  origin,\n  muted,\n  children,\n}: {\n  id: string;\n  field: Field;\n  partner?: string;\n  origin?: string;\n  muted?: boolean;\n  children: React.ReactNode;\n}) {\n  const form = useForm();\n  return (\n    <Piece\n      id={id}\n      part={field.name}\n      partner={partner}\n      origin={origin}\n      enter=\"fade\"\n      exit=\"fade\"\n      className={cn(\"whitespace-nowrap\", muted && \"text-current/45\", field.className)}\n    >\n      {children}\n      <Underline filled={!muted} active={form.active === field.name} invalid={!!form.errors[field.name]} />\n    </Piece>\n  );\n}\n\nconst INPUT =\n  \"col-start-1 row-start-1 m-0 w-full min-w-0 appearance-none border-0 bg-transparent p-0 text-inherit outline-none placeholder:text-current/45 read-only:cursor-default \" +\n  // Keep the browser's autofill tint (and its forced text color) from boxing in the blank.\n  \"[&:-webkit-autofill]:[transition:background-color_0s_600000s,color_0s_600000s] [&:autofill]:[transition:background-color_0s_600000s,color_0s_600000s]\";\n\nfunction TextBlank({ field, prefix, suffix }: BlankProps) {\n  const form = useForm();\n  const { name } = field;\n  const value = form.valueOf(name) as string;\n  const custom = !!field.options;\n  const id = custom ? `f:${name}:custom` : `f:${name}:input`;\n  const error = form.errors[name];\n  return (\n    <span data-unit=\"\" className=\"whitespace-nowrap\">\n      <Glue atoms={prefix} />\n      <Piece\n        key={id}\n        id={id}\n        part={name}\n        origin={value ? `${id}~0` : undefined}\n        enter={custom ? \"fade\" : \"rise\"}\n        exit={custom ? \"fade\" : \"fold\"}\n        className={cn(\n          \"inline-grid max-w-full grid-cols-[minmax(0,auto)] align-baseline\",\n          suffix.length > 0 && \"max-w-[calc(100%-0.6em)]\",\n          field.className,\n        )}\n      >\n        {/* Hidden copies set the width and the input fills it. While the blank is\n            active it keeps at least its placeholder's room, so the first letters\n            typed don't pull the rest of the sentence back. */}\n        <span aria-hidden className=\"invisible col-start-1 row-start-1 overflow-hidden pr-[0.08em] whitespace-pre\">\n          {value || field.placeholder || \"\\u00a0\".repeat(6)}\n        </span>\n        {value && form.active === name && field.placeholder ? (\n          <span aria-hidden className=\"invisible col-start-1 row-start-1 overflow-hidden whitespace-pre\">\n            {field.placeholder}\n          </span>\n        ) : null}\n        <input\n          data-blank={name}\n          name={name}\n          type={field.type ?? \"text\"}\n          value={value}\n          size={1}\n          placeholder={field.placeholder}\n          autoComplete={field.autoComplete}\n          spellCheck={field.type === \"email\" || field.type === \"url\" ? false : undefined}\n          enterKeyHint={form.upNext(name) ? \"next\" : \"send\"}\n          aria-label={field.label}\n          aria-required={field.required || undefined}\n          aria-invalid={error ? true : undefined}\n          aria-describedby={form.describedBy(field)}\n          readOnly={!form.editable}\n          className={INPUT}\n          {...form.text(field)}\n        />\n        <Underline filled={!!value} active={form.active === name} invalid={!!error} />\n      </Piece>\n      <Glue atoms={suffix} />\n    </span>\n  );\n}\n\nfunction OneBlank({ field, prefix, suffix }: BlankProps) {\n  const form = useForm();\n  const { name } = field;\n  const value = form.valueOf(name) as string;\n  const open = form.active === name;\n  const label = value ? labelOf(field, value) : null;\n  const id = value ? `f:${name}:v:${value}` : `f:${name}:ph`;\n  return (\n    <span className=\"whitespace-nowrap\">\n      <Glue atoms={prefix} />\n      <button\n        type=\"button\"\n        data-blank={name}\n        aria-haspopup=\"listbox\"\n        aria-expanded={open}\n        aria-controls={open ? form.ids(name).options : undefined}\n        aria-label={label ? `${field.label}, ${label}` : field.label}\n        aria-describedby={form.describedBy(field)}\n        aria-disabled={!form.editable || undefined}\n        className=\"cursor-pointer align-baseline outline-none aria-disabled:cursor-default\"\n        {...form.choice(field)}\n      >\n        <Answer\n          key={id}\n          id={id}\n          field={field}\n          partner={value ? `c:${name}:${value}` : undefined}\n          origin={value ? `${id}~0` : undefined}\n          muted={!value}\n        >\n          {label ?? field.placeholder ?? \"\\u00a0\".repeat(6)}\n        </Answer>\n      </button>\n      <input type=\"hidden\" name={name} value={value} />\n      <Glue atoms={suffix} />\n    </span>\n  );\n}\n\n/** A pick-any blank: its picks as separate words joined with list grammar, so the list can wrap. */\nfunction ManyBlank({ field, prefix, suffix }: BlankProps) {\n  const form = useForm();\n  const { name } = field;\n  const picks = form.valueOf(name) as string[];\n  const open = form.active === name;\n  const bits: (ListBit | null)[] = picks.length\n    ? listBits(field, picks, form.locale)\n    : [{ kind: \"word\", id: `f:${name}:ph`, text: field.placeholder ?? \"\\u00a0\".repeat(6) }];\n  const units = group(bits);\n  const label = picks.length\n    ? `${field.label}, ${listFormat(form.locale).format(picks.map((value) => labelOf(field, value)))}`\n    : field.label;\n  return (\n    <span\n      role=\"button\"\n      tabIndex={form.editable ? 0 : -1}\n      data-blank={name}\n      aria-haspopup=\"listbox\"\n      aria-expanded={open}\n      aria-controls={open ? form.ids(name).options : undefined}\n      aria-label={label}\n      aria-describedby={form.describedBy(field)}\n      aria-disabled={!form.editable || undefined}\n      className=\"cursor-pointer whitespace-normal outline-none aria-disabled:cursor-default\"\n      {...form.choice(field)}\n    >\n      {units.map((unit, i) => (\n        <React.Fragment key={unit.key}>\n          {i > 0 ? \" \" : null}\n          <span className=\"whitespace-nowrap\">\n            {i === 0 ? <Glue atoms={prefix} /> : null}\n            {unit.atoms.map((bit) =>\n              bit.kind === \"pick\" ? (\n                <Answer key={bit.id} id={bit.id} field={field} partner={`c:${name}:${bit.value}`} origin={`${bit.id}~0`}>\n                  {bit.label}\n                </Answer>\n              ) : picks.length ? (\n                <Piece key={bit.id} id={bit.id} aria-hidden enter=\"fade\" exit=\"fade\">\n                  {bit.text}\n                </Piece>\n              ) : (\n                <Answer key={bit.id} id={bit.id} field={field} muted>\n                  {bit.text}\n                </Answer>\n              ),\n            )}\n            {i === units.length - 1 ? <Glue atoms={suffix} /> : null}\n          </span>\n        </React.Fragment>\n      ))}\n      {picks.map((value) => (\n        <input key={value} type=\"hidden\" name={name} value={value} />\n      ))}\n    </span>\n  );\n}\n\n/** After sending: the answer as plain words that wrap like the rest of the sentence. */\nfunction SettledBlank({ field, prefix, suffix }: BlankProps) {\n  const form = useForm();\n  const { name } = field;\n  const value = form.valueOf(name);\n  const typing = form.typing(field);\n  const bits: ({ id: string; text: string; origin?: string; muted?: boolean } | null)[] = [];\n  const words = (text: string, from: string) => {\n    let k = 0;\n    text.split(/(\\s+)/).forEach((bit) => {\n      if (!bit) return;\n      if (/^\\s/.test(bit)) bits.push(null);\n      else bits.push({ id: `${from}~${k++}`, text: bit, origin: from });\n    });\n  };\n  if (isEmpty(value)) {\n    const from = !field.options ? `f:${name}:input` : typing ? `f:${name}:custom` : `f:${name}:ph`;\n    bits.push({ id: `f:${name}:dash`, text: \"—\", origin: from, muted: true });\n  } else if (Array.isArray(value)) {\n    for (const bit of listBits(field, value, form.locale)) {\n      if (bit === null) bits.push(null);\n      else if (bit.kind === \"pick\") words(bit.label, bit.id);\n      else bits.push({ id: bit.id, text: bit.text });\n    }\n  } else if (typing) words(value, field.options ? `f:${name}:custom` : `f:${name}:input`);\n  else words(labelOf(field, value), `f:${name}:v:${value}`);\n\n  const units = group(bits);\n  return (\n    <span>\n      {units.map((unit, i) => (\n        <React.Fragment key={unit.key}>\n          {i > 0 ? \" \" : null}\n          <span className=\"whitespace-nowrap\">\n            {i === 0 ? <Glue atoms={prefix} /> : null}\n            {unit.atoms.map((bit) => (\n              <Piece\n                key={bit.id}\n                id={bit.id}\n                origin={bit.origin}\n                aria-hidden\n                enter=\"fade\"\n                exit=\"fade\"\n                // A word longer than the line (a long email) breaks rather than overflowing.\n                className={cn(\"whitespace-normal [overflow-wrap:anywhere]\", bit.muted && \"text-current/45\", field.className)}\n              >\n                {bit.text}\n              </Piece>\n            ))}\n            {i === units.length - 1 ? <Glue atoms={suffix} /> : null}\n          </span>\n        </React.Fragment>\n      ))}\n      {(Array.isArray(value) ? value : [value]).map((v, i) => (\n        <input key={i} type=\"hidden\" name={name} value={v} />\n      ))}\n    </span>\n  );\n}\n\n/* ── The tray ───────────────────────────────────────────── */\n\nconst CHIP =\n  \"relative inline-flex h-10 cursor-pointer items-center rounded-full border px-4 text-[15px] leading-none whitespace-nowrap outline-none select-none \" +\n  \"transition-[background-color,border-color] duration-200 ease-out before:absolute before:-inset-1 before:content-[''] \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current\";\n\n// AnimatePresence pops a leaving tray out of the flow through this ref, so the\n// tray's height changes as soon as the next blank opens.\nfunction Tray({ field, ref }: { field: Field; ref?: React.Ref<HTMLDivElement> }) {\n  const form = useForm();\n  const { name } = field;\n  const ids = form.ids(name);\n  const value = form.valueOf(name);\n  const error = form.errors[name];\n  const hint = hintFor(field);\n  const typing = form.typing(field);\n  const keyHint = !form.keys\n    ? null\n    : !field.options || typing\n      ? form.upNext(name)\n        ? \"Enter for the next blank\"\n        : \"Enter to send\"\n      : field.multiple\n        ? \"Space picks · Enter when done\"\n        : \"Arrows move · Enter picks\";\n  const options = field.options ?? [];\n  return (\n    <motion.div\n      ref={ref}\n      className=\"w-full\"\n      initial={{ opacity: 0 }}\n      // Arrives just after the last blank's tray has faded, so their words never overlap.\n      animate={{ opacity: 1, transition: { ...SENTENCE.fade, delay: 0.1 } }}\n      exit={{ opacity: 0, transition: { duration: 0.1 } }}\n    >\n      <p className=\"flex flex-wrap items-baseline gap-x-2 font-sans text-[13px] leading-5\">\n        <span id={ids.label} className=\"font-medium\">\n          {field.label}\n        </span>\n        {hint ? (\n          <span id={ids.hint} className=\"opacity-60\">\n            {hint}\n          </span>\n        ) : null}\n        {keyHint ? <span className=\"ml-auto opacity-45 max-sm:hidden\">{keyHint}</span> : null}\n      </p>\n      {error ? (\n        <p id={ids.error} className=\"mt-1 font-sans text-[13px] leading-5 text-(--sentence-error)\">\n          {error.message}\n        </p>\n      ) : null}\n      {field.options ? (\n        <div\n          id={ids.options}\n          role=\"listbox\"\n          aria-labelledby={ids.label}\n          aria-multiselectable={field.multiple || undefined}\n          onKeyDown={(event) => form.optionsKeyDown(field, event)}\n          className=\"mt-3 flex flex-wrap gap-2\"\n        >\n          {options.map((option, i) => {\n            const picked = Array.isArray(value) ? value.includes(option.value) : !typing && value === option.value;\n            return (\n              <Chip key={option.value} index={i} picked={picked} label={option.label} value={option.value} field={field} />\n            );\n          })}\n          {field.custom && !field.multiple ? (\n            <Chip index={options.length} picked={typing} label={otherLabel(field)} field={field} />\n          ) : null}\n        </div>\n      ) : null}\n    </motion.div>\n  );\n}\n\nfunction Chip({\n  field,\n  value,\n  label,\n  picked,\n  index,\n}: {\n  field: Field;\n  /** Unset for the \"Other\" chip. */\n  value?: string;\n  label: string;\n  picked: boolean;\n  index: number;\n}) {\n  const form = useForm();\n  const delay = index * SENTENCE.options;\n  return (\n    <motion.button\n      type=\"button\"\n      role=\"option\"\n      tabIndex={-1}\n      aria-selected={picked}\n      aria-label={label}\n      data-option={value}\n      initial={form.reduced ? { opacity: 0 } : { opacity: 0, y: 8 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ ...SENTENCE.flow, delay, opacity: { ...SENTENCE.fade, delay } }}\n      // Picking keeps focus where it was (the blank, or the text being typed).\n      onMouseDown={(event) => event.preventDefault()}\n      onClick={(event) => {\n        if (event.detail === 0) return;\n        if (value === undefined) form.other(field);\n        else form.pick(field, value);\n      }}\n      className={cn(\n        CHIP,\n        picked\n          ? \"border-dashed border-current/30 bg-transparent\"\n          : \"border-transparent bg-current/[0.07] hover:bg-current/[0.12]\",\n      )}\n    >\n      {value === undefined || !picked ? (\n        value === undefined ? (\n          label\n        ) : (\n          <Piece id={`c:${field.name}:${value}`} kind=\"chip\" partner={`f:${field.name}:v:${value}`}>\n            {label}\n          </Piece>\n        )\n      ) : (\n        // The label's imprint stays in the outline, so a pick can be found and sent back.\n        <span\n          aria-hidden\n          className=\"opacity-30 transition-opacity delay-200 duration-300 starting:opacity-0 motion-reduce:delay-0\"\n        >\n          {label}\n        </span>\n      )}\n    </motion.button>\n  );\n}\n\n/* ── SentenceFormField ──────────────────────────────────── */\n\nexport interface SentenceFormFieldProps {\n  /** The field's name in the values and in the submitted FormData. */\n  name: string;\n  /** Names the field for screen readers, and heads the tray while the blank is active. */\n  label: string;\n  /** Muted hint shown in the empty blank, like \"your name\". It sets the blank's width until something's typed. */\n  placeholder?: string;\n  /** A line in the tray beside the label. Options default to \"Pick one\" or \"Pick any\". */\n  hint?: React.ReactNode;\n  /** Makes the blank a choice. Strings, or `{ value, label }` when the submitted value differs. */\n  options?: readonly SentenceFormOption[];\n  /** Lets several options be picked. The value becomes a string[] in the order they were picked. */\n  multiple?: boolean;\n  /** Adds an \"Other\" chip that turns the blank into a text input. Pass a string to rename the chip. */\n  custom?: boolean | string;\n  /** Input type for a text blank. \"email\" and \"url\" are checked on send. */\n  type?: \"text\" | \"email\" | \"tel\" | \"url\";\n  /** Must be filled (or, with `multiple`, have a pick) before the form sends. */\n  required?: boolean;\n  /** The value it starts with, until it's changed. */\n  defaultValue?: string | string[];\n  /** Autofill hint for a text blank, like \"name\", \"email\" or \"organization\". */\n  autoComplete?: string;\n  /** Classes for the blank's words: the input, the picks and the answer once sent. */\n  className?: string;\n}\n\n/**\n * One blank in a `SentenceForm`. Write it inline in the sentence; it's\n * read by the form, which renders it as a text input or a choice.\n */\nexport function SentenceFormField(props: SentenceFormFieldProps): React.ReactNode {\n  throw new Error(\n    `<SentenceFormField name=\"${props.name}\"> must be written inside <SentenceForm>'s sentence (directly, or inside fragments and conditionals).`,\n  );\n}\n"
    }
  ]
}