{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rule-builder",
  "type": "registry:ui",
  "title": "Rule builder",
  "description": "Conditions built from rows: a field, an operator and a value, joined by And or Or, grouped and nested, added, removed and reordered by a handle. A row picked up by its handle rises into a raised block and the others make room; the new order is committed once it has settled. Space lifts a row for the arrow keys.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/combobox.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/input.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/number-field.json",
    "https://littmd.vercel.app/r/segmented.json",
    "https://littmd.vercel.app/r/select.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/rule-builder.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/rule-builder 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Rule builder: when this, and that, or any of these, built from rows (components.md, \"Rule builder\"; the moodboard's\n * #34). A row is a field, an operator and a value that read as a sentence (\"Seniority is Manager\"); a group joins its\n * rows with one And or Or; a group can hold a group; rows are added, removed and moved by a handle. Think an expense\n * policy: when expense status is Submitted, and seniority is Manager, and any of three things about the expense.\n *\n * - Anatomy: the lead (\"When\") at the small size and the title weight in --litt-fg, in sentence case like every word in litt\n *   (R5, R8). Each group opens with its join, a Segmented control (sm, \"And\" and \"Or\", hidden legend \"Join conditions\n *   with\"), at the head of its rail: a 1px --litt-line-strong line that drops from under the join and turns into\n *   each row at its middle. The rail is chrome, so it takes no hue (R7). A condition row is flat (R3): a hairline and the\n *   top highlight at the control radius, holding the handle (a ghost icon-only button with the grip), the field and the\n *   operator (Selects, sm: raised, since you press them) and the value the field asks for: an Input for words and a\n *   Number field for a number (cuts, since you type into them), a Select for one choice, and a Combobox with picks as\n *   raised tags for an operator that takes several (\"is any of\": \"Food & drink ×\"). Remove is a ghost icon-only x. Below\n *   a 480px row (a container query) the value wraps under the field and the operator, and below 352px the operator and\n *   the value each take a line of their own, so a phone never scrolls sideways. A locked row (the rule's trigger)\n *   shows a lock where the handle is and its field and operator as words in --litt-fg-muted; only its value is a control,\n *   and it stays first. A nested group is a flat panel (--litt-surface-2, a hairline, the surface radius) with its handle,\n *   \"Group · 3 conditions\" and remove, then its own join, rail and rows. Under every group: Add condition and, while\n *   maxDepth allows, Add group, secondary sm buttons. Never the accent (R6): the caller's Save is the view's one.\n * - Flat: no frames and no rail (flat-pill.md, rules 2 and 5). A row is bare, its grey fields set apart by space, a nested group\n *   a grey panel with no hairline, and the held row the grey bubble. Only an error keeps its --litt-danger edge.\n * - Hand (R4, motion/hand.ts): take a handle and the row takes the raised block's material at once (--litt-block,\n *   --litt-block-lift, the shadow swapped, never faded, R2) and follows the pointer 1:1 along its group, held where it was\n *   taken and never past the first free place or the last. The rows it passes move out of its way by transform, each\n *   on its own settle. Let go and it settles into its place, every row lands, and only then is the new order committed:\n *   one onValueChange per move, and a layout effect puts every row's offset back to 0 before the new order paints, so\n *   the data and the picture change in one frame. Escape puts it back. Rows move only within their own group.\n * - Fun mode (motion/fun.tsx, a trial): nothing here reads it. useSettle does: the rows trade places and the dropped\n *   row lands on fun mode's throw spring, a release carries its speed and may coast a place further, and a pull past\n *   the first or last place gives like a rubber band. It rests on exactly the frame Calm does.\n * - Keys and assistive tech: the builder is a group named by `label`. Space or Enter on a handle picks the row up\n *   (aria-pressed, the raised look), ↑ and ↓ move it a place with the same settle, Space or Enter drops it (the one\n *   commit), Escape puts it back, and leaving the handle drops it. A polite status says each step (\"Picked up Seniority\n *   is Manager, position 2 of 3\", \"Position 3 of 3\", \"Moved … to position 3 of 3\") and what was added or removed. Every\n *   control is named by its row (\"Field, condition 2\"); a nested group is a group named \"Group, 3 conditions\". A new\n *   row focuses its field; a removed row hands focus to the next handle, else the previous, else Add condition.\n * - Motion: rows move by transform only (each row's y, the hand's settle). A new row fades in at 0.98 on the\n *   responsive tier, a removed one fades out on the exit token, and the rows around them snap, as heights do. No\n *   Motion `layout` on a row: it would replay the swap a second time. Under reduced motion every settle lands at once.\n * - Touch: a handle owns every direction (touch-action none, data-no-drag), so a drag anywhere else scrolls the page;\n *   handles and removes are 44px targets on a finger, and the sm controls grow to 44px.\n * - Disabled: every control lies flat and leaves the tab order; the handles keep their grip in --litt-fg-disabled and do\n *   nothing, remove is gone, and the Add buttons lie flat. The layout does not move.\n * - Errors (R1, R5): `errors` by condition id draws the row's edge in --litt-danger and says why under its controls, linked\n *   to the row with aria-describedby.\n * - Forced colors: rows, panels and the held row keep a real border; the rail is a border, so it stays.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type MutableRefObject,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { AnimatePresence, motion, useMotionValue, type MotionValue } from \"motion/react\";\nimport { Button } from \"./button\";\nimport { Combobox } from \"./combobox\";\nimport { GripIcon, LockIcon, PlusIcon, XIcon } from \"./icons\";\nimport { Input } from \"./input\";\nimport { NumberField } from \"./number-field\";\nimport { Segmented } from \"./segmented\";\nimport { Select, type SelectOption } from \"./select\";\nimport { useExit, useTransition } from \"../../lib/litt/motion\";\nimport { useHand, useSettle, type Settle } from \"../../lib/litt/motion/hand\";\n\n/** How a group combines its children: and needs every one, or needs any one. */\nexport type RuleBuilderJoin = \"and\" | \"or\";\n\n/** One choice of a choice field. */\nexport type RuleBuilderOption = {\n  /** What a condition stores and a form sends. Unique within its field. */\n  value: string;\n  /** What people read in the value control: \"Food & drink\". */\n  label: string;\n};\n\n/** The words between a field and its value. */\nexport type RuleBuilderOperator = {\n  /** What a condition stores: \"is\", \"any-of\". Unique within its field. */\n  id: string;\n  /** The words, lower case, so a row reads as a sentence: \"is\", \"is not\", \"is any of\". */\n  label: string;\n  /** Takes several values, as \"is any of\" does. Only a choice field's operator can. */\n  many?: boolean;\n};\n\n/** Something a condition can test. A choice field must say its options, so a value list is never empty. */\nexport type RuleBuilderField = {\n  /** What a condition stores as its field. */\n  id: string;\n  /** What people read in the field control: \"Seniority\". */\n  label: string;\n  /**\n   * The operators it offers, in order; a new row takes the first. Without them, its type's: words take is, is not and\n   * contains; a number is, is more than and is less than; a choice is, is not, is any of and is none of.\n   */\n  operators?: RuleBuilderOperator[];\n} & (\n  | {\n      /** What its value is: words (an Input) or a number (a Number field). */\n      type: \"text\" | \"number\";\n    }\n  | {\n      /** One of its options (a Select), or several for an operator with many (a Combobox). */\n      type: \"choice\";\n      /** The choices, in order. */\n      options: RuleBuilderOption[];\n    }\n);\n\n/** What a condition compares with: words, a number, an option's value, several values for an operator with many, or null while empty. */\nexport type RuleBuilderValue = string | number | string[] | null;\n\n/** One row: a field, an operator and a value. */\nexport type RuleBuilderCondition = {\n  /** Says this node is a condition. */\n  kind: \"condition\";\n  /** Unique in the rule: the row's key, so it keeps its place and its motion when the rule changes. Rows the builder adds get one. */\n  id: string;\n  /** A field's id. */\n  field: string;\n  /** One of that field's operator ids. */\n  operator: string;\n  /** What it compares with, in the shape its operator takes. */\n  value: RuleBuilderValue;\n  /** The rule's trigger: it stays first, shows its field and operator as words, and is never moved or removed. */\n  locked?: boolean;\n};\n\n/** Conditions and groups joined by and or or. The rule itself is a group. */\nexport type RuleBuilderGroup = {\n  /** Says this node is a group. */\n  kind: \"group\";\n  /** Unique in the rule. */\n  id: string;\n  /** How its children combine. */\n  join: RuleBuilderJoin;\n  /** Its conditions and groups, in order. */\n  children: RuleBuilderNode[];\n};\n\nexport type RuleBuilderNode = RuleBuilderCondition | RuleBuilderGroup;\n\nexport type RuleBuilderProps = {\n  /** What the rule decides, in a few words: \"Expense approval\". It names the builder for assistive tech. */\n  label: string;\n  /** The word above the rows, in sentence case. */\n  lead?: string;\n  /** What a condition can test, in the order the field control lists them. Add condition starts a row on the first. */\n  fields: RuleBuilderField[];\n  /** The rule, when you control it: the root group. Pair it with onValueChange. */\n  value?: RuleBuilderGroup;\n  /** The rule it starts with when you do not control it. Without one, an empty rule joined by and. */\n  defaultValue?: RuleBuilderGroup;\n  /** Called with the whole new rule after each change: a pick, a typed value, a join, an add, a remove, and a move once it has settled. */\n  onValueChange?: (value: RuleBuilderGroup) => void;\n  /** How many levels of groups may nest inside the rule. 0 is a flat list with no Add group. */\n  maxDepth?: number;\n  /** Problems to show, by condition id: \"Pick at least one type\". The row draws its edge in the danger color and says it under its controls. */\n  errors?: Record<string, string>;\n  /** Makes the builder read-only: every control flat and skipped by Tab, the handles still and remove gone. */\n  disabled?: boolean;\n  /** The field name a form sends the rule under, as one JSON string. */\n  name?: string;\n  /** Extra classes for the builder, such as a width. It fills its container. */\n  className?: string;\n};\n\n/* ---------------------------------------------------------------- the rule as data */\n\nconst OPERATORS: Record<RuleBuilderField[\"type\"], RuleBuilderOperator[]> = {\n  text: [\n    { id: \"is\", label: \"is\" },\n    { id: \"is-not\", label: \"is not\" },\n    { id: \"contains\", label: \"contains\" },\n  ],\n  number: [\n    { id: \"is\", label: \"is\" },\n    { id: \"more-than\", label: \"is more than\" },\n    { id: \"less-than\", label: \"is less than\" },\n  ],\n  choice: [\n    { id: \"is\", label: \"is\" },\n    { id: \"is-not\", label: \"is not\" },\n    { id: \"any-of\", label: \"is any of\", many: true },\n    { id: \"none-of\", label: \"is none of\", many: true },\n  ],\n};\n\nconst JOINS: { value: RuleBuilderJoin; label: string }[] = [\n  { value: \"and\", label: \"And\" },\n  { value: \"or\", label: \"Or\" },\n];\n\nconst operatorsOf = (field: RuleBuilderField) => field.operators ?? OPERATORS[field.type];\nconst fieldOf = (fields: readonly RuleBuilderField[], id: string) => fields.find((f) => f.id === id);\n/** Whether an operator takes several values: only a choice field's can. */\nconst takesMany = (field: RuleBuilderField | undefined, operator: string) =>\n  field?.type === \"choice\" && !!operatorsOf(field).find((o) => o.id === operator)?.many;\n\nlet minted = 0;\n/** An id for a node made by a hand, never during a render, so a server render and the client agree. */\nfunction mint(): string {\n  if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\") return crypto.randomUUID();\n  minted += 1;\n  return `rule-${Date.now().toString(36)}-${minted}`;\n}\n\n/** Narrows a node: a group has children, a condition a field. */\nexport function isGroup(node: RuleBuilderNode): node is RuleBuilderGroup {\n  return node.kind === \"group\";\n}\n\n/**\n * A new condition on `field`, with its first operator and an empty value.\n *\n * @param field The field it tests.\n * @param id Its id. Made up for you when left out; pass your own for a rule written at the top of a module, so a server render and the client agree.\n */\nexport function newCondition(field: RuleBuilderField, id: string = mint()): RuleBuilderCondition {\n  const operator = operatorsOf(field)[0]?.id ?? \"\";\n  return { kind: \"condition\", id, field: field.id, operator, value: takesMany(field, operator) ? [] : null };\n}\n\n/**\n * A new group.\n *\n * @param join How its children combine.\n * @param children What it starts with.\n * @param id Its id. Made up for you when left out.\n */\nexport function newGroup(join: RuleBuilderJoin = \"and\", children: RuleBuilderNode[] = [], id: string = mint()): RuleBuilderGroup {\n  return { kind: \"group\", id, join, children };\n}\n\n/**\n * A condition made whole after a change: a new field resets the operator to the field's first and empties the value;\n * a new operator moves the value between one and several (\"is Travel\" becomes \"is any of Travel\"). So a row never\n * holds a value its controls cannot show.\n */\nfunction cohere(before: RuleBuilderCondition, change: Partial<Pick<RuleBuilderCondition, \"field\" | \"operator\" | \"value\">>, fields: readonly RuleBuilderField[]): RuleBuilderCondition {\n  const next = { ...before, ...change };\n  const field = fieldOf(fields, next.field);\n  if (next.field !== before.field) {\n    const operator = field ? (operatorsOf(field)[0]?.id ?? \"\") : \"\";\n    return { ...next, operator, value: takesMany(field, operator) ? [] : null };\n  }\n  const many = takesMany(field, next.operator);\n  const v = next.value;\n  if (many && !Array.isArray(v)) return { ...next, value: v === null || v === \"\" ? [] : [String(v)] };\n  if (!many && Array.isArray(v)) return { ...next, value: v[0] ?? null };\n  return next;\n}\n\n/** How many conditions a group holds, its groups' included. */\nfunction conditionsIn(group: RuleBuilderGroup): number {\n  return group.children.reduce((n, c) => n + (isGroup(c) ? conditionsIn(c) : 1), 0);\n}\n\nconst conditionWords = (n: number) => (n === 0 ? \"no conditions\" : n === 1 ? \"1 condition\" : `${n} conditions`);\n\n/** A node as words, for its handle's name and the status: \"Seniority is Manager\", \"group of 3 conditions\". */\nfunction describe(node: RuleBuilderNode, fields: readonly RuleBuilderField[]): string {\n  if (isGroup(node)) return `group of ${conditionWords(conditionsIn(node))}`;\n  const field = fieldOf(fields, node.field);\n  const operator = field ? operatorsOf(field).find((o) => o.id === node.operator) : undefined;\n  const head = `${field?.label ?? node.field} ${operator?.label ?? node.operator}`;\n  const label = (v: string) => (field?.type === \"choice\" ? (field.options.find((o) => o.value === v)?.label ?? v) : v);\n  const v = node.value;\n  if (v === null || v === \"\" || (Array.isArray(v) && v.length === 0)) return `${head}, no value yet`;\n  return `${head} ${Array.isArray(v) ? v.map(label).join(\", \") : typeof v === \"number\" ? String(v) : label(v)}`;\n}\n\n/* The tree changes only through these: pure, by id, and the same tree back when nothing changed. */\n\nfunction editNode(node: RuleBuilderNode, id: string, fn: (node: RuleBuilderNode) => RuleBuilderNode | null): RuleBuilderNode | null {\n  if (node.id === id) return fn(node);\n  if (!isGroup(node)) return node;\n  let changed = false;\n  const children: RuleBuilderNode[] = [];\n  for (const child of node.children) {\n    const next = editNode(child, id, fn);\n    if (next !== child) changed = true;\n    if (next) children.push(next);\n  }\n  return changed ? { ...node, children } : node;\n}\n\n/** The rule with the node `id` replaced by what `fn` returns; null removes it. The root is never removed. */\nfunction edit(rule: RuleBuilderGroup, id: string, fn: (node: RuleBuilderNode) => RuleBuilderNode | null): RuleBuilderGroup {\n  const next = editNode(rule, id, fn);\n  return next && isGroup(next) ? next : rule;\n}\n\nconst appendTo = (rule: RuleBuilderGroup, group: string, node: RuleBuilderNode) =>\n  edit(rule, group, (g) => (isGroup(g) ? { ...g, children: [...g.children, node] } : g));\n\n/** The rule without the node `id`, a group with all it holds. A locked condition stays. */\nconst removeNode = (rule: RuleBuilderGroup, id: string) => edit(rule, id, (n) => (!isGroup(n) && n.locked ? n : null));\n\nconst moveWithin = (rule: RuleBuilderGroup, group: string, from: number, to: number) =>\n  edit(rule, group, (g) => {\n    if (!isGroup(g) || from === to) return g;\n    const children = [...g.children];\n    children.splice(to, 0, ...children.splice(from, 1));\n    return { ...g, children };\n  });\n\n/** How many locked conditions lead a group: no row may be moved above them. */\nconst lockedLead = (group: RuleBuilderGroup) => {\n  const i = group.children.findIndex((c) => isGroup(c) || !c.locked);\n  return i < 0 ? group.children.length : i;\n};\n\n/* ---------------------------------------------------------------- reordering one group's rows */\n\ntype Rect = { top: number; height: number };\n\n/**\n * Where every row would sit if the row at `from` moved to `to`, as an offset from where it is now. The rows are laid\n * out again in the new order from the first one's top, with the gaps measured between places, so a nested group (a\n * tall row) and the list's gap need no constant. Pure.\n */\nfunction offsetsFor(rects: readonly Rect[], from: number, to: number): number[] {\n  const order = rects.map((_, i) => i);\n  order.splice(to, 0, ...order.splice(from, 1));\n  const offsets = rects.map(() => 0);\n  let top = rects[0]?.top ?? 0;\n  order.forEach((i, at) => {\n    offsets[i] = top - rects[i].top;\n    const gap = at + 1 < rects.length ? rects[at + 1].top - (rects[at].top + rects[at].height) : 0;\n    top += rects[i].height + gap;\n  });\n  return offsets;\n}\n\n/**\n * Where the row at `from`, dragged `dy` px, belongs: going down it has passed a row once its bottom edge is past that\n * row's middle, going up once its top edge is, in the layout measured at the press. `travel` is how far it may go,\n * from the first free place (`first`, below any locked rows) to the last. Pure.\n */\nfunction reorderPlan(rects: readonly Rect[], from: number, dy: number, first: number): { to: number; offsets: number[]; travel: [lo: number, hi: number] } {\n  const last = rects.length - 1;\n  const me = rects[from];\n  const lo = rects[first].top - me.top;\n  const hi = rects[last].top + rects[last].height - (me.top + me.height);\n  const top = me.top + Math.min(hi, Math.max(lo, dy));\n  const middle = (i: number) => rects[i].top + rects[i].height / 2;\n  let to = from;\n  for (let i = from + 1; i <= last; i++) if (top + me.height > middle(i)) to = i;\n  for (let i = from - 1; i >= first; i--) if (top < middle(i)) to = i;\n  return { to, offsets: offsetsFor(rects, from, to), travel: [lo, hi] };\n}\n\n/** One row's part in a reorder: its place, its offset and its settle, and where that settle is headed. */\ntype Slot = {\n  /** The row's place in the list, measured at a press. */\n  place: HTMLElement | null;\n  /** What moves: the row itself, inside its place. */\n  body: HTMLElement | null;\n  y: MotionValue<number>;\n  settle: Settle;\n  /** The offset its settle is headed for; NaN while a hand holds it. */\n  aim: number;\n  /** True once it has arrived at `aim`. */\n  landed: boolean;\n  /** Run once it arrives. */\n  waiting: (() => void)[];\n};\n\n/** A row picked up, by the pointer or the keys. */\ntype Lift = { id: string; from: number; to: number; rects: Rect[]; ids: string[]; via: \"pointer\" | \"keys\"; name: string };\n\ntype Reorder = {\n  /** The row in a hand, or picked up by the keys: it draws as the raised block. */\n  held: string | null;\n  /** A row joins the reorder for as long as it is mounted. */\n  register: (id: string, slot: Slot) => () => void;\n  /** What a row's handle listens to. */\n  handle: (id: string) => {\n    onPointerDown: (e: PointerEvent<HTMLElement>) => void;\n    onKeyDown: (e: KeyboardEvent<HTMLElement>) => void;\n    onBlur: () => void;\n  };\n};\n\n/* What every lift ends with: the row back at rest in its place, its offset 0, nothing pending. */\nfunction rest(s: Slot) {\n  s.settle.hold();\n  s.y.jump(0);\n  /* Motion writes the style on its next frame; the new order is in the DOM now, so the old offset must not paint on it. */\n  if (s.body) s.body.style.transform = \"none\";\n  s.aim = 0;\n  s.landed = true;\n  s.waiting = [];\n}\n\n/**\n * One group's reordering. A press on a handle measures the places, the hand moves the held row 1:1 (useHand), and every\n * row it passes settles to its new offset on its own useSettle. Let go, and the held row settles into its place; once\n * every row has landed the move is committed (one onValueChange), and the layout effect below puts every offset back\n * to 0 in the same frame as the new order. The keys take the same path with no pointer.\n */\nfunction useReorder(group: RuleBuilderGroup): Reorder {\n  const builder = useBuilder();\n  const hand = useHand();\n  const slots = useRef(new Map<string, Slot>());\n  const lift = useRef<Lift | null>(null);\n  const landing = useRef(false);\n  const pending = useRef<{ id: string; focus: boolean } | null>(null);\n  const [held, setHeld] = useState<string | null>(null);\n  const ids = group.children.map((c) => c.id);\n  const now = useRef({ group, ids, first: lockedLead(group), builder });\n  now.current = { group, ids, first: lockedLead(group), builder };\n\n  const order = ids.join(\"\\n\");\n  useLayoutEffect(() => {\n    const p = pending.current;\n    pending.current = null;\n    if (!p && !lift.current) return;\n    if (!p) hand.cancel(); // the rule changed under a lift: drop it where the new order puts it\n    lift.current = null;\n    landing.current = false;\n    slots.current.forEach(rest);\n    if (!p) setHeld(null);\n    const handle = p?.focus ? document.getElementById(now.current.builder.idFor(\"handle\", p.id)) : null;\n    if (handle && document.activeElement !== handle) handle.focus({ preventScroll: true });\n  }, [order, hand]);\n\n  const register = useCallback((id: string, slot: Slot) => {\n    slots.current.set(id, slot);\n    return () => {\n      if (slots.current.get(id) === slot) slots.current.delete(id);\n    };\n  }, []);\n\n  const measure = (list: string[]): Rect[] =>\n    list.map((id) => {\n      const el = slots.current.get(id)?.place;\n      return { top: el?.offsetTop ?? 0, height: el?.offsetHeight ?? 0 };\n    });\n  const slotsOf = (list: string[]) => list.flatMap((id) => slots.current.get(id) ?? []);\n\n  const arrive = (s: Slot) => () => {\n    s.landed = true;\n    s.waiting.splice(0).forEach((then) => then());\n  };\n  /* A row headed somewhere already keeps going; a new place starts a new settle. */\n  const send = (s: Slot, target: number) => {\n    if (s.aim === target) return;\n    s.aim = target;\n    s.landed = false;\n    s.settle.to([target], { commit: arrive(s) });\n  };\n  const sendAll = (l: Lift, offsets: number[], except?: string) =>\n    l.ids.forEach((id, i) => {\n      const s = slots.current.get(id);\n      if (s && id !== except) send(s, offsets[i]);\n    });\n  const whenAll = (list: Slot[], done: () => void) => {\n    let left = list.length;\n    const one = () => {\n      left -= 1;\n      if (left === 0) done();\n    };\n    if (left === 0) return done();\n    list.forEach((s) => (s.landed ? one() : s.waiting.push(one)));\n  };\n\n  const putBack = (l: Lift) => {\n    landing.current = true;\n    sendAll(l, l.ids.map(() => 0));\n    whenAll(slotsOf(l.ids), () => {\n      lift.current = null;\n      landing.current = false;\n      setHeld(null);\n      now.current.builder.say(`${capital(l.name)}, back at position ${l.from + 1} of ${l.ids.length}.`);\n    });\n  };\n\n  /* Every row has landed: commit the move, or end the lift where it began. */\n  const finish = (l: Lift) => {\n    const { builder: b, group: g } = now.current;\n    const n = l.ids.length;\n    if (l.to === l.from) {\n      lift.current = null;\n      landing.current = false;\n      setHeld(null);\n      b.say(`${capital(l.name)}, still at position ${l.from + 1} of ${n}.`);\n      return;\n    }\n    const mine = slots.current.get(l.id);\n    const p = { id: l.id, focus: !!mine?.place?.contains(document.activeElement) };\n    pending.current = p;\n    setHeld(null);\n    b.apply((rule) => moveWithin(rule, g.id, l.from, l.to));\n    b.say(`Moved ${l.name} to position ${l.to + 1} of ${n}.`);\n    /* A controlled caller may keep the old order. Then nothing re-renders, and the rows settle back. */\n    requestAnimationFrame(() =>\n      requestAnimationFrame(() => {\n        if (pending.current !== p) return;\n        pending.current = null;\n        putBack(l);\n      }),\n    );\n  };\n\n  const drop = (l: Lift) => {\n    landing.current = true;\n    sendAll(l, offsetsFor(l.rects, l.from, l.to));\n    whenAll(slotsOf(l.ids), () => finish(l));\n  };\n\n  const pick = (id: string, via: Lift[\"via\"]): Lift | null => {\n    const { ids: list, first, group: g, builder: b } = now.current;\n    const from = list.indexOf(id);\n    if (b.disabled || lift.current || landing.current || from < first) return null;\n    return { id, from, to: from, rects: measure(list), ids: list, via, name: describe(g.children[from], b.fields) };\n  };\n\n  const handle = (id: string) => ({\n    onPointerDown: (e: PointerEvent<HTMLElement>) => {\n      const l = pick(id, \"pointer\");\n      const mine = slots.current.get(id);\n      if (!l || !mine) return;\n      const { first } = now.current;\n      hand.begin(e, {\n        axis: \"y\",\n        onClaim: () => {\n          lift.current = l;\n          mine.aim = Number.NaN;\n          mine.landed = false;\n          setHeld(id);\n          now.current.builder.say(`Picked up ${l.name}.`);\n        },\n        onMove: ({ along }) => {\n          const plan = reorderPlan(l.rects, l.from, along, first);\n          mine.y.set(mine.settle.limit(along, plan.travel[0], plan.travel[1]).shown);\n          if (plan.to === l.to) return;\n          l.to = plan.to;\n          sendAll(l, plan.offsets, id);\n        },\n        onRelease: (r) => {\n          if (!r.claimed) return;\n          if (r.cancelled) return putBack(l);\n          landing.current = true;\n          mine.settle.release({\n            at: [mine.y.get()],\n            speeds: [r.speed],\n            land: ({ points }) => {\n              const plan = reorderPlan(l.rects, l.from, points[0], first);\n              l.to = plan.to;\n              return [plan.offsets[l.from]];\n            },\n            commit: arrive(mine),\n          });\n          mine.aim = offsetsFor(l.rects, l.from, l.to)[l.from];\n          sendAll(l, offsetsFor(l.rects, l.from, l.to), id);\n          whenAll(slotsOf(l.ids), () => finish(l));\n        },\n      });\n    },\n    onKeyDown: (e: KeyboardEvent<HTMLElement>) => {\n      const l = lift.current;\n      const ours = [\"ArrowUp\", \"ArrowDown\", \" \", \"Enter\", \"Escape\"].includes(e.key);\n      if (landing.current) {\n        if (ours && l?.id === id) e.preventDefault();\n        return;\n      }\n      if (l) {\n        if (l.via !== \"keys\" || l.id !== id) return;\n        if (e.key === \"ArrowUp\" || e.key === \"ArrowDown\") {\n          e.preventDefault();\n          const to = Math.min(l.ids.length - 1, Math.max(now.current.first, l.to + (e.key === \"ArrowUp\" ? -1 : 1)));\n          /* At either end the row stays, and the status still says where it is, so the key is never met with silence. */\n          if (to !== l.to) {\n            l.to = to;\n            sendAll(l, offsetsFor(l.rects, l.from, to));\n          }\n          now.current.builder.say(`Position ${to + 1} of ${l.ids.length}.`);\n        } else if (e.key === \" \" || e.key === \"Enter\") {\n          e.preventDefault();\n          drop(l);\n        } else if (e.key === \"Escape\") {\n          e.preventDefault();\n          e.stopPropagation();\n          putBack(l);\n        }\n        return;\n      }\n      if (e.key !== \" \" && e.key !== \"Enter\") return;\n      e.preventDefault();\n      const next = pick(id, \"keys\");\n      if (!next) return;\n      lift.current = next;\n      setHeld(id);\n      now.current.builder.say(\n        `Picked up ${next.name}, position ${next.from + 1} of ${next.ids.length}. The arrow keys move it, Space drops it, Escape puts it back.`,\n      );\n    },\n    onBlur: () => {\n      const l = lift.current;\n      if (l && l.via === \"keys\" && l.id === id && !landing.current) drop(l);\n    },\n  });\n\n  return { held, register, handle };\n}\n\nconst capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);\n\n/* ---------------------------------------------------------------- the builder's shared state */\n\ntype BuilderContext = {\n  fields: readonly RuleBuilderField[];\n  fieldOptions: SelectOption[];\n  maxDepth: number;\n  disabled: boolean;\n  errors: Record<string, string>;\n  /** Every edit funnels through here: a pure tree function in, onValueChange out. */\n  apply: (fn: (rule: RuleBuilderGroup) => RuleBuilderGroup) => void;\n  /** What the polite status says next. */\n  say: (words: string) => void;\n  /** Focus the element with this id once the next render is on screen. */\n  focusLater: (id: string) => void;\n  /** A DOM id for a part of a node: its handle, its field control, its group's Add condition. */\n  idFor: (part: \"handle\" | \"field\" | \"add\" | \"error\", node: string) => string;\n  hintId: string;\n  /** True once the builder has rendered once: rows that come after fade in, rows that were there do not. */\n  mounted: MutableRefObject<boolean>;\n};\n\nconst Builder = createContext<BuilderContext | null>(null);\n\nfunction useBuilder(): BuilderContext {\n  const ctx = useContext(Builder);\n  if (!ctx) throw new Error(\"A rule builder part must sit inside RuleBuilder.\");\n  return ctx;\n}\n\n/* ---------------------------------------------------------------- the parts */\n\n/* A row at rest is flat; a held one is the raised block, at once (R2: a shadow never fades). */\n/* A row's corners: the control radius, but never past half its one-line height, so a row that wraps (a narrow\n   builder, a field of picks, an error under it) keeps the corners it had on one line, concentric with the fields in\n   it, instead of turning into a lozenge at the pill. A group's are the surface radius, or the control radius up to 4px past the rows' cap\n   when that is rounder, so a group never holds rows rounder than itself. Below the pill both\n   are as before. */\nconst ROW_RADIUS = \"rounded-[min(var(--litt-radius-control),calc(var(--litt-control-md)/2))]\";\nconst GROUP_RADIUS = \"rounded-[max(var(--litt-radius-surface),min(var(--litt-radius-control),calc(var(--litt-control-md)/2+4px)))]\";\nconst FLAT_ROW = \"shadow-[inset_0_0_0_1px_var(--litt-line),var(--litt-highlight)] in-[[data-material=flat]]:shadow-none\";\nconst ERROR_ROW = \"shadow-[inset_0_0_0_1px_var(--litt-danger),var(--litt-highlight)]\";\nconst HELD = \"bg-litt-block shadow-(--litt-block-lift)\";\n\n/* The rail: each place draws its piece, a line from the place above (or the join) turning into it at its middle, and\n   the line runs on past every place but the last. It is drawn on the place, which never moves, so it stays still while\n   the rows trade places. */\nconst RAIL =\n  \"before:pointer-events-none before:absolute before:-top-2 before:-left-3 before:h-[calc(50%+0.5rem)] before:w-3 \" +\n  \"before:rounded-bl-litt-control before:border-b before:border-l before:border-litt-line-strong in-[[data-material=flat]]:before:hidden\";\nconst RAIL_ON = \"after:pointer-events-none after:absolute after:top-[calc(50%-var(--litt-radius-control))] after:bottom-0 after:-left-3 after:border-l after:border-litt-line-strong in-[[data-material=flat]]:after:hidden\";\n\n/* The handle's cell, so a lock and a still grip take the handle's room. */\nconst CELL = \"grid h-litt-control-sm w-litt-control-sm shrink-0 place-items-center pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\";\n\n/* One line from 480px. Under it the value wraps under the field and the operator; under 352px (a phone, or a group\n   on one) the field keeps the first line and the operator and the value each take a line of their own. */\nconst ROW =\n  \"grid min-h-litt-control-md grid-cols-[auto_minmax(0,9rem)_minmax(0,8rem)_minmax(8rem,1fr)_auto] items-center gap-2 p-1 \" +\n  \"@max-[30rem]:grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_auto] @max-[22rem]:grid-cols-[auto_minmax(0,1fr)_auto]\";\n/* Under 352px the operator, the value and an error each span the row and take the next free line, so a locked row\n   (no operator) closes up. */\nconst FIELD_CELL = \"min-w-0 col-start-2 row-start-1\";\nconst OPERATOR_CELL = \"min-w-0 col-start-3 row-start-1 @max-[22rem]:col-span-full @max-[22rem]:row-start-auto\";\nconst WORDS_CELL = \"min-w-0 col-span-2 col-start-2 row-start-1 @max-[22rem]:col-span-1 @max-[22rem]:col-start-2\";\nconst VALUE_CELL =\n  \"min-w-0 col-start-4 row-start-1 @max-[30rem]:col-span-2 @max-[30rem]:col-start-2 @max-[30rem]:row-start-2 \" +\n  \"@max-[22rem]:col-span-full @max-[22rem]:row-start-auto\";\nconst END_CELL = \"col-start-5 row-start-1 @max-[30rem]:col-start-4 @max-[22rem]:col-start-3\";\nconst ERROR_CELL =\n  \"col-span-3 col-start-2 row-start-2 pb-1 text-litt-small text-litt-danger @max-[30rem]:col-span-2 @max-[30rem]:row-start-3 \" +\n  \"@max-[22rem]:col-span-full @max-[22rem]:row-start-auto @max-[22rem]:px-1\";\n\nfunction ValueControl({ condition, label, invalid, describedBy }: { condition: RuleBuilderCondition; label: string; invalid: boolean; describedBy?: string }) {\n  const { fields, disabled, apply } = useBuilder();\n  const field = fieldOf(fields, condition.field);\n  const set = (value: RuleBuilderValue) =>\n    apply((rule) => edit(rule, condition.id, (n) => (isGroup(n) ? n : cohere(n, { value }, fields))));\n  const v = condition.value;\n  if (field?.type === \"choice\") {\n    const options = field.options.map((o) => ({ value: o.value, label: o.label }));\n    if (takesMany(field, condition.operator)) {\n      return (\n        <Combobox\n          multiple\n          size=\"sm\"\n          hideLabel\n          label={label}\n          placeholder=\"Values\"\n          options={options}\n          value={Array.isArray(v) ? v : []}\n          onValueChange={set}\n          disabled={disabled}\n        />\n      );\n    }\n    return (\n      <Select\n        size=\"sm\"\n        aria-label={label}\n        aria-describedby={describedBy}\n        placeholder=\"Value\"\n        options={options}\n        value={typeof v === \"string\" ? v : \"\"}\n        onValueChange={set}\n        invalid={invalid}\n        disabled={disabled}\n      />\n    );\n  }\n  if (field?.type === \"number\") {\n    return <NumberField size=\"sm\" hideLabel label={label} value={typeof v === \"number\" ? v : null} onValueChange={set} disabled={disabled} />;\n  }\n  return (\n    <Input\n      size=\"sm\"\n      aria-label={label}\n      aria-describedby={describedBy}\n      placeholder=\"Value\"\n      value={typeof v === \"string\" ? v : \"\"}\n      onChange={(e) => set(e.target.value === \"\" ? null : e.target.value)}\n      invalid={invalid}\n      disabled={disabled}\n    />\n  );\n}\n\n/** One condition: the handle (or a lock), the field, the operator, the value and remove. */\nfunction ConditionRow({ condition, index, count, held, handle, remove }: { condition: RuleBuilderCondition; index: number; count: number; held: boolean; handle: ReactNode; remove: ReactNode }) {\n  const { fields, fieldOptions, disabled, errors, apply, idFor } = useBuilder();\n  const field = fieldOf(fields, condition.field);\n  const operators = field ? operatorsOf(field) : [];\n  const error = errors[condition.id];\n  const errorId = idFor(\"error\", condition.id);\n  const which = `condition ${index + 1}`;\n  const set = (change: Partial<Pick<RuleBuilderCondition, \"field\" | \"operator\">>) =>\n    apply((rule) => edit(rule, condition.id, (n) => (isGroup(n) ? n : cohere(n, change, fields))));\n  return (\n    <div\n      role=\"group\"\n      aria-label={`Condition ${index + 1} of ${count}`}\n      aria-describedby={error ? errorId : undefined}\n      className={\"@container \" + ROW_RADIUS + \" forced-colors:border \" + (held ? HELD : error ? ERROR_ROW : FLAT_ROW)}\n    >\n      <div className={ROW}>\n        {condition.locked ? (\n          <span className={CELL + \" text-litt-fg-subtle\"}>\n            <LockIcon />\n          </span>\n        ) : (\n          handle\n        )}\n        {condition.locked ? (\n          <p className={WORDS_CELL + \" truncate px-litt-control-pad-sm text-litt-small text-litt-fg-muted\"}>\n            {field?.label ?? condition.field} {operators.find((o) => o.id === condition.operator)?.label ?? condition.operator}\n          </p>\n        ) : (\n          <>\n            <div className={FIELD_CELL}>\n              <Select\n                size=\"sm\"\n                id={idFor(\"field\", condition.id)}\n                aria-label={`Field, ${which}`}\n                options={fieldOptions}\n                value={condition.field}\n                onValueChange={(f) => set({ field: f })}\n                disabled={disabled}\n              />\n            </div>\n            <div className={OPERATOR_CELL}>\n              <Select\n                size=\"sm\"\n                aria-label={`Operator, ${which}`}\n                options={operators.map((o) => ({ value: o.id, label: o.label }))}\n                value={condition.operator}\n                onValueChange={(o) => set({ operator: o })}\n                disabled={disabled}\n              />\n            </div>\n          </>\n        )}\n        <div className={VALUE_CELL}>\n          <ValueControl condition={condition} label={`Value, ${which}`} invalid={!!error} describedBy={error ? errorId : undefined} />\n        </div>\n        <div className={END_CELL}>{remove ?? <span aria-hidden=\"true\" className={CELL} />}</div>\n        {error && (\n          <p id={errorId} className={ERROR_CELL}>\n            {error}\n          </p>\n        )}\n      </div>\n    </div>\n  );\n}\n\n/** A nested group: its handle, \"Group · 3 conditions\" and remove, then its own join, rows and Add condition. */\nfunction GroupPanel({ group, depth, held, handle, remove }: { group: RuleBuilderGroup; depth: number; held: boolean; handle: ReactNode; remove: ReactNode }) {\n  const words = conditionWords(conditionsIn(group));\n  return (\n    <div\n      role=\"group\"\n      aria-label={`Group, ${words}`}\n      className={\"@container/panel grid gap-2 \" + GROUP_RADIUS + \" p-1 pb-3 forced-colors:border \" + (held ? HELD : \"bg-litt-surface-2 \" + FLAT_ROW)}\n    >\n      <div className=\"flex min-w-0 items-center gap-2\">\n        {handle}\n        <p className=\"min-w-0 flex-1 truncate text-litt-small\">\n          <span className=\"font-litt-title text-litt-fg\">Group</span>\n          <span className=\"text-litt-fg-subtle\"> · {capital(words)}</span>\n        </p>\n        {remove}\n      </div>\n      <div className=\"px-3 @max-[30rem]/panel:px-1\">\n        <GroupBody group={group} depth={depth} />\n      </div>\n    </div>\n  );\n}\n\n/** One child of a group: its place on the rail, the row or panel that moves inside it, its handle and its remove. */\nfunction Item({ node, group, index, depth, reorder }: { node: RuleBuilderNode; group: RuleBuilderGroup; index: number; depth: number; reorder: Reorder }) {\n  const b = useBuilder();\n  const y = useMotionValue(0);\n  const settle = useSettle([y]);\n  const slot = useRef<Slot | null>(null);\n  if (!slot.current) slot.current = { place: null, body: null, y, settle, aim: 0, landed: true, waiting: [] };\n  const { register } = reorder;\n  useLayoutEffect(() => register(node.id, slot.current!), [register, node.id]);\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const held = reorder.held === node.id;\n  const count = group.children.length;\n  const locked = !isGroup(node) && !!node.locked;\n  const what = describe(node, b.fields);\n\n  const handle = b.disabled ? (\n    <span aria-hidden=\"true\" className={CELL + \" text-litt-fg-disabled\"}>\n      <GripIcon />\n    </span>\n  ) : (\n    <Button\n      variant=\"ghost\"\n      size=\"sm\"\n      iconOnly\n      id={b.idFor(\"handle\", node.id)}\n      aria-label={`Move ${what}`}\n      aria-pressed={held}\n      aria-describedby={b.hintId}\n      data-no-drag=\"\"\n      {...reorder.handle(node.id)}\n      className=\"cursor-grab touch-none active:cursor-grabbing pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\"\n    >\n      <GripIcon />\n    </Button>\n  );\n\n  const onRemove = () => {\n    const at = (k: number) => {\n      const s = group.children[k];\n      return s && !(!isGroup(s) && s.locked) ? b.idFor(\"handle\", s.id) : null;\n    };\n    b.focusLater(at(index + 1) ?? at(index - 1) ?? b.idFor(\"add\", group.id));\n    b.apply((rule) => removeNode(rule, node.id));\n    b.say(`Removed ${what}.`);\n  };\n  const remove =\n    b.disabled || locked ? null : (\n      <Button\n        variant=\"ghost\"\n        size=\"sm\"\n        iconOnly\n        aria-label={`Remove ${what}`}\n        onClick={onRemove}\n        className=\"pointer-coarse:h-litt-control-md pointer-coarse:w-litt-control-md\"\n      >\n        <XIcon />\n      </Button>\n    );\n\n  return (\n    <motion.li\n      ref={(el: HTMLLIElement | null) => {\n        slot.current!.place = el;\n      }}\n      initial={b.mounted.current ? { opacity: 0, scale: 0.98 } : false}\n      animate={{ opacity: 1, scale: 1, transition: enter }}\n      exit={{ opacity: 0, transition: exit }}\n      className={\"relative \" + RAIL + (index < count - 1 ? \" \" + RAIL_ON : \"\")}\n    >\n      <motion.div\n        ref={(el: HTMLDivElement | null) => {\n          slot.current!.body = el;\n        }}\n        style={{ y }}\n        className={\"relative \" + (held ? \"z-10\" : \"\")}\n      >\n        {isGroup(node) ? (\n          <GroupPanel group={node} depth={depth + 1} held={held} handle={handle} remove={remove} />\n        ) : (\n          <ConditionRow condition={node} index={index} count={count} held={held} handle={handle} remove={remove} />\n        )}\n      </motion.div>\n    </motion.li>\n  );\n}\n\n/** A group's join, its rows on the rail, and its Add buttons. */\nfunction GroupBody({ group, depth }: { group: RuleBuilderGroup; depth: number }) {\n  const b = useBuilder();\n  const reorder = useReorder(group);\n  const n = group.children.length;\n  const add = (node: RuleBuilderNode, focus: string, words: string) => {\n    b.focusLater(focus);\n    b.apply((rule) => appendTo(rule, group.id, node));\n    b.say(words);\n  };\n  const addCondition = () => {\n    const c = newCondition(b.fields[0]);\n    add(c, b.idFor(\"field\", c.id), \"Added a condition.\");\n  };\n  const addGroup = () => {\n    const c = newCondition(b.fields[0]);\n    add(newGroup(group.join === \"and\" ? \"or\" : \"and\", [c]), b.idFor(\"field\", c.id), \"Added a group with one condition.\");\n  };\n  return (\n    <div className=\"grid min-w-0 gap-2\">\n      {n > 0 ? (\n        <>\n          <Segmented\n            size=\"sm\"\n            legend=\"Join conditions with\"\n            options={JOINS}\n            value={group.join}\n            onValueChange={(join) => b.apply((rule) => edit(rule, group.id, (g) => (isGroup(g) ? { ...g, join } : g)))}\n            disabled={b.disabled}\n          />\n          <ul role=\"list\" className=\"relative grid min-w-0 gap-2 pl-6\">\n            <AnimatePresence>\n              {group.children.map((child, i) => (\n                <Item key={child.id} node={child} group={group} index={i} depth={depth} reorder={reorder} />\n              ))}\n            </AnimatePresence>\n          </ul>\n        </>\n      ) : (\n        <p className=\"text-litt-small text-litt-fg-subtle\">No conditions yet.</p>\n      )}\n      <div className={\"flex flex-wrap gap-2 \" + (n > 0 ? \"pl-6\" : \"\")}>\n        <Button size=\"sm\" icon={<PlusIcon />} id={b.idFor(\"add\", group.id)} onClick={addCondition} disabled={b.disabled || !b.fields.length}>\n          Add condition\n        </Button>\n        {depth < b.maxDepth && (\n          <Button size=\"sm\" icon={<PlusIcon />} onClick={addGroup} disabled={b.disabled || !b.fields.length}>\n            Add group\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n}\n\nconst EMPTY: RuleBuilderGroup = { kind: \"group\", id: \"rule\", join: \"and\", children: [] };\n\n/** When this, and that, or any of these: a rule as rows of field, operator and value, joined by And or Or, grouped, and reordered by their handle. */\nexport function RuleBuilder({\n  label,\n  lead = \"When\",\n  fields,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  maxDepth = 1,\n  errors = {},\n  disabled = false,\n  name,\n  className = \"\",\n}: RuleBuilderProps) {\n  const uid = useId();\n  const [inner, setInner] = useState<RuleBuilderGroup>(defaultValue ?? EMPTY);\n  const rule = valueProp ?? inner;\n\n  /* The latest rule and callback, so an edit made while a row settles builds on what is on screen now. */\n  const latest = useRef({ rule, controlled: valueProp !== undefined, onValueChange });\n  latest.current = { rule, controlled: valueProp !== undefined, onValueChange };\n  const apply = useCallback((fn: (rule: RuleBuilderGroup) => RuleBuilderGroup) => {\n    const { rule: before, controlled, onValueChange: report } = latest.current;\n    const next = fn(before);\n    if (next === before) return;\n    if (!controlled) {\n      latest.current.rule = next;\n      setInner(next);\n    }\n    report?.(next);\n  }, []);\n\n  const [said, setSaid] = useState(\"\");\n  const say = useCallback((words: string) => setSaid((s) => (s === words ? words + \" \" : words)), []);\n\n  const focusNext = useRef<string | null>(null);\n  useEffect(() => {\n    const id = focusNext.current;\n    if (!id) return;\n    focusNext.current = null;\n    document.getElementById(id)?.focus();\n  });\n\n  const mounted = useRef(false);\n  useEffect(() => {\n    mounted.current = true;\n  }, []);\n\n  const hintId = `${uid}-hint`;\n  const fieldOptions = useMemo(() => fields.map((f) => ({ value: f.id, label: f.label })), [fields]);\n  const ctx: BuilderContext = {\n    fields,\n    fieldOptions,\n    maxDepth,\n    disabled,\n    errors,\n    apply,\n    say,\n    focusLater: (id) => {\n      focusNext.current = id;\n    },\n    idFor: (part, node) => `${uid}-${node}-${part}`,\n    hintId,\n    mounted,\n  };\n\n  return (\n    <Builder.Provider value={ctx}>\n      <div role=\"group\" aria-label={label} className={\"grid min-w-0 gap-3 \" + className}>\n        <p className=\"text-litt-small font-litt-title text-litt-fg\">{lead}</p>\n        <GroupBody group={rule} depth={0} />\n        <span id={hintId} hidden>\n          Space picks it up, the arrow keys move it, Space drops it, Escape puts it back.\n        </span>\n        <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {said}\n        </div>\n        {name && <input type=\"hidden\" name={name} value={JSON.stringify(rule)} disabled={disabled} />}\n      </div>\n    </Builder.Provider>\n  );\n}\n",
      "target": "components/litt/rule-builder.tsx"
    }
  ],
  "docs": "Import RuleBuilder from components/litt/rule-builder. Give it a label and fields ({ id, label, type: text, number or choice, options, operators }) and hold the tree in state with value and onValueChange, or pass name and defaultValue in a form (it posts one JSON string). newCondition, newGroup and isGroup build and read the tree. errors by condition id says a problem under its row. A filter on one table column is a TableFilter.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
