{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "nl-edit-panel",
  "title": "Plain-language edit panel",
  "description": "Edit the policy in plain English behind a human gate: a proposer (your LLM, or the bundled deterministic demo parser) returns a proposed policy plus its diff, the user reviews and applies or discards. Nothing lands without the click.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "input",
    "https://approvals-ui.vercel.app/r/approvals-core.json"
  ],
  "files": [
    {
      "path": "components/approvals-ui/nl-edit-panel.tsx",
      "content": "\"use client\";\n\nimport {\n  CircleMinus,\n  CirclePlus,\n  Loader2,\n  MessageCircleQuestion,\n  Pencil,\n  Sparkles,\n} from \"lucide-react\";\nimport { useState } from \"react\";\n\nimport type { EditProposal, Proposer } from \"@/lib/approvals-ui/edit-ops\";\nimport type { ApprovalPolicy } from \"@/lib/approvals-ui/policy\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { hasChanges, summarizeChanges } from \"@/lib/approvals-ui/diff\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Edit the policy in plain language, behind a human gate: the proposer only\n * ever PROPOSES. The parent renders the returned diff on the canvas, and\n * nothing lands until Apply is clicked.\n *\n * Bring your own proposer: the bundled demoProposer is deterministic (no\n * model, no key). Swap it for an LLM that emits EditOp JSON and the UI does\n * not change.\n */\nexport type NlEditPanelProps = {\n  policy: ApprovalPolicy;\n  proposer: Proposer;\n  proposal: EditProposal | null;\n  onProposalChange: (proposal: EditProposal | null) => void;\n  onApply: (policy: ApprovalPolicy) => void;\n  /** Block Apply (for example when the proposed policy has validation errors). */\n  blockApply?: boolean;\n  blockApplyReason?: string;\n  suggestions?: string[];\n  placeholder?: string;\n  className?: string;\n};\n\nexport const NlEditPanel = ({\n  policy,\n  proposer,\n  proposal,\n  onProposalChange,\n  onApply,\n  blockApply = false,\n  blockApplyReason,\n  suggestions = [],\n  placeholder = \"Above $50k also require the CFO\",\n  className,\n}: NlEditPanelProps) => {\n  const [instruction, setInstruction] = useState(\"\");\n  const [busy, setBusy] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const propose = async (text: string) => {\n    const trimmed = text.trim();\n    if (trimmed.length === 0 || busy) return;\n    setBusy(true);\n    setError(null);\n    onProposalChange(null);\n    try {\n      onProposalChange(await proposer(trimmed, policy));\n    } catch (error_) {\n      setError(error_ instanceof Error ? error_.message : String(error_));\n    } finally {\n      setBusy(false);\n    }\n  };\n\n  const apply = () => {\n    if (!proposal || blockApply) return;\n    onApply(proposal.proposed);\n    onProposalChange(null);\n    setInstruction(\"\");\n  };\n\n  const discard = () => {\n    onProposalChange(null);\n  };\n\n  const isMutating = proposal !== null && hasChanges(proposal.changes);\n\n  return (\n    <div className={cn(\"space-y-3\", className)}>\n      <form\n        className=\"flex items-center gap-2\"\n        onSubmit={(event) => {\n          event.preventDefault();\n          void propose(instruction);\n        }}\n      >\n        <Input\n          value={instruction}\n          onChange={(event) => setInstruction(event.target.value)}\n          placeholder={placeholder}\n          disabled={busy}\n          className=\"h-8 text-xs\"\n        />\n        <Button type=\"submit\" size=\"sm\" disabled={busy || instruction.trim().length === 0}>\n          {busy ? <Loader2 className=\"size-3.5 animate-spin\" /> : <Sparkles className=\"size-3.5\" />}\n          Propose\n        </Button>\n      </form>\n\n      {suggestions.length > 0 && proposal === null && !busy && (\n        <div className=\"flex flex-wrap gap-1.5\">\n          {suggestions.map((suggestion) => (\n            <button\n              key={suggestion}\n              type=\"button\"\n              onClick={() => {\n                setInstruction(suggestion);\n                void propose(suggestion);\n              }}\n              className=\"text-muted-foreground hover:bg-muted hover:text-foreground rounded-full border px-2.5 py-1 text-[11px] transition-colors\"\n            >\n              {suggestion}\n            </button>\n          ))}\n        </div>\n      )}\n\n      {error && <p className=\"text-destructive text-xs\">{error}</p>}\n\n      {proposal?.clarify && (\n        <div className=\"space-y-2 rounded-lg border p-3\">\n          <p className=\"flex items-start gap-2 text-xs\">\n            <MessageCircleQuestion className=\"text-muted-foreground mt-0.5 size-3.5 shrink-0\" />\n            {proposal.clarify.question}\n          </p>\n          {proposal.clarify.options && proposal.clarify.options.length > 0 && (\n            <div className=\"flex flex-wrap gap-1.5 pl-5\">\n              {proposal.clarify.options.map((option) => (\n                <button\n                  key={option}\n                  type=\"button\"\n                  onClick={() => setInstruction(option)}\n                  className=\"text-muted-foreground hover:bg-muted hover:text-foreground rounded-full border px-2.5 py-0.5 text-[11px] transition-colors\"\n                >\n                  {option}\n                </button>\n              ))}\n            </div>\n          )}\n        </div>\n      )}\n\n      {proposal && !proposal.clarify && (\n        <div className=\"space-y-2.5 rounded-lg border p-3\">\n          <div className=\"space-y-1\">\n            <p className=\"text-xs font-medium\">Proposal: {summarizeChanges(proposal.changes)}</p>\n            {proposal.reason && <p className=\"text-muted-foreground text-xs\">{proposal.reason}</p>}\n          </div>\n\n          {isMutating && (\n            <ul className=\"space-y-1\">\n              {proposal.changes\n                .filter((change) => change.kind !== \"unchanged\")\n                .map((change) => (\n                  <li\n                    key={`${change.kind}-${change.id}`}\n                    className=\"flex items-center gap-2 text-xs\"\n                  >\n                    {change.kind === \"added\" && (\n                      <CirclePlus className=\"size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400\" />\n                    )}\n                    {change.kind === \"changed\" && (\n                      <Pencil className=\"size-3.5 shrink-0 text-amber-500\" />\n                    )}\n                    {change.kind === \"removed\" && (\n                      <CircleMinus className=\"size-3.5 shrink-0 text-red-600 dark:text-red-400\" />\n                    )}\n                    <span className=\"truncate\">{change.label}</span>\n                    {change.kind === \"changed\" && (\n                      <span className=\"text-muted-foreground truncate font-mono text-[10px]\">\n                        {change.fields.join(\", \")}\n                      </span>\n                    )}\n                  </li>\n                ))}\n            </ul>\n          )}\n\n          <div className=\"flex items-center gap-2\">\n            {isMutating && (\n              <Button size=\"sm\" onClick={apply} disabled={blockApply}>\n                Apply\n              </Button>\n            )}\n            <Button size=\"sm\" variant=\"ghost\" onClick={discard}>\n              Discard\n            </Button>\n            {blockApply && blockApplyReason && (\n              <p className=\"text-destructive text-[11px]\">{blockApplyReason}</p>\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n",
      "type": "registry:component",
      "target": "components/approvals-ui/nl-edit-panel.tsx"
    },
    {
      "path": "lib/approvals-ui/demo-proposer.ts",
      "content": "import { type EditOp, type EditProposal, proposeEdits, type Proposer } from \"./edit-ops\";\nimport { type ApprovalPolicy, type ApprovalStep, isApprovalStep, isTerminalStep } from \"./policy\";\n\n/**\n * A deterministic, dependency-free proposer so the edit panel works with no\n * model and no API key. It understands a handful of phrasings (thresholds,\n * approvers, quorums, renames, removals) and answers with the same\n * EditProposal contract a real LLM proposer would.\n *\n * In production you swap this for a model: have it emit `EditOp` JSON\n * (validate with `editOpSchema`), then call `proposeEdits`. The UI does not\n * change.\n */\n\nexport const demoInstructions = [\n  \"Above $50k also require the CFO\",\n  \"Add Maria Chen (Controller) to Manager review\",\n  \"Require 2 of the approvers on Finance review\",\n  \"Rename Manager review to Budget owner review\",\n  \"Remove the Director sign-off\",\n] as const;\n\nconst normalize = (text: string): string => {\n  return text\n    .toLowerCase()\n    .replaceAll(/[^a-z0-9\\s]/g, \" \")\n    .replaceAll(/\\s+/g, \" \")\n    .trim();\n};\n\nconst cleanRole = (text: string): string => {\n  return text\n    .replaceAll(/\\b(approval|review|sign\\s*off|gate|step)\\b/gi, \" \")\n    .replaceAll(/[^\\w\\s&-]/g, \" \")\n    .replaceAll(/\\s+/g, \" \")\n    .trim();\n};\n\nconst titleCase = (text: string): string => {\n  if (text === text.toUpperCase() && text.length <= 4) return text;\n  return text\n    .split(\" \")\n    .map((w) =>\n      w === w.toUpperCase() && w.length <= 4 ? w : w.charAt(0).toUpperCase() + w.slice(1)\n    )\n    .join(\" \");\n};\n\nconst slugify = (text: string): string => {\n  return normalize(text).replaceAll(/\\s/g, \"-\") || \"step\";\n};\n\nconst parseAmount = (raw: string, hasK: boolean): number => {\n  const value = Number(raw.replaceAll(\",\", \"\"));\n  return hasK ? value * 1000 : value;\n};\n\nconst matchSteps = (policy: ApprovalPolicy, query: string): ApprovalStep[] => {\n  const q = normalize(query);\n  if (q.length === 0) return [];\n  const gates = policy.steps.filter(isApprovalStep);\n  const byLabel = gates.filter((s) => {\n    const label = normalize(s.label);\n    return label.includes(q) || q.includes(label);\n  });\n  if (byLabel.length > 0) return byLabel;\n  return gates.filter((s) =>\n    s.approvers.some((a) => {\n      const title = normalize(a.title);\n      return title.length > 0 && (title.includes(q) || q.includes(title));\n    })\n  );\n};\n\nconst clarifyProposal = (\n  policy: ApprovalPolicy,\n  question: string,\n  options?: string[]\n): EditProposal => {\n  return proposeEdits(policy, [{ op: \"clarify\", question, options }]);\n};\n\ntype StepResolution =\n  { step: ApprovalStep; clarify?: undefined } | { step?: undefined; clarify: EditProposal };\n\nconst resolveStep = (policy: ApprovalPolicy, query: string): StepResolution => {\n  const matches = matchSteps(policy, query);\n  const [only] = matches;\n  if (matches.length === 1 && only) return { step: only };\n  if (matches.length === 0) {\n    return {\n      clarify: clarifyProposal(\n        policy,\n        `I could not find a step matching \"${query.trim()}\". Which one did you mean?`,\n        policy.steps.filter(isApprovalStep).map((s) => s.label)\n      ),\n    };\n  }\n  return {\n    clarify: clarifyProposal(\n      policy,\n      `\"${query.trim()}\" matches several steps. Which one?`,\n      matches.map((s) => s.label)\n    ),\n  };\n};\n\n/** The gate that feeds an approved terminal, else the last gate. */\nconst lastGate = (policy: ApprovalPolicy): ApprovalStep | undefined => {\n  const approvedIds = new Set(\n    policy.steps\n      .filter(isTerminalStep)\n      .filter((t) => t.outcome === \"approved\")\n      .map((t) => t.id)\n  );\n  const gates = policy.steps.filter(isApprovalStep);\n  return gates.find((g) => g.next.some((id) => approvedIds.has(id))) ?? gates.at(-1);\n};\n\nconst uniqueId = (policy: ApprovalPolicy, base: string): string => {\n  if (policy.steps.every((s) => s.id !== base)) return base;\n  let i = 2;\n  while (policy.steps.some((s) => s.id === `${base}-${i}`)) i += 1;\n  return `${base}-${i}`;\n};\n\n/**\n * A mandatory regex capture group. When the overall match succeeded, a\n * non-optional group is always present; this asserts that so the value is a\n * `string` (not `string | undefined`) without an `as` cast.\n */\nconst group = (value: string | undefined): string => {\n  if (value === undefined) throw new Error(\"Expected a matched capture group.\");\n  return value;\n};\n\nconst proposeDemoEdit = (instruction: string, policy: ApprovalPolicy): EditProposal => {\n  const text = instruction.trim();\n\n  // \"Above $50k (also) require the CFO\"\n  const threshold = text.match(\n    /(?:above|over)\\s+\\$?([\\d][\\d,.]*)\\s*(k)?\\b[,:]?\\s*(?:also\\s+)?require\\s+(?:the\\s+)?(.+)$/i\n  );\n  if (threshold) {\n    const [, rawAmount, kSuffix, rawRole] = threshold;\n    const value = parseAmount(group(rawAmount), kSuffix !== undefined);\n    const role = cleanRole(group(rawRole));\n    if (!Number.isFinite(value) || role.length === 0) {\n      return clarifyProposal(\n        policy,\n        \"I could not read the amount or the role in that instruction.\"\n      );\n    }\n    const existing = matchSteps(policy, role);\n    const [onlyExisting] = existing;\n    if (existing.length === 1 && onlyExisting) {\n      return proposeEdits(\n        policy,\n        [{ op: \"set-threshold\", stepId: onlyExisting.id, value }],\n        `Set the \"${onlyExisting.label}\" threshold to ${value.toLocaleString(\"en-US\")}.`\n      );\n    }\n    const after = lastGate(policy);\n    if (!after) {\n      return clarifyProposal(policy, \"There is no approval gate to attach this to yet.\");\n    }\n    const title = titleCase(role);\n    const op: EditOp = {\n      op: \"insert-approval-after\",\n      afterId: after.id,\n      step: {\n        id: uniqueId(policy, slugify(role)),\n        label: `${title} approval`,\n        when: { kind: \"leaf\", field: \"amount\", op: \">\", value },\n        approvers: [{ name: null, title }],\n      },\n    };\n    return proposeEdits(\n      policy,\n      [op],\n      `Add a ${title} gate for requests above ${value.toLocaleString(\"en-US\")}. The seat is unassigned until you name someone.`\n    );\n  }\n\n  // \"Require 2 of the approvers on Finance review\"\n  const quorum = text.match(\n    /require\\s+(\\d+)\\s+of\\s+(?:the\\s+)?(?:\\d+\\s+)?(?:approvers?\\s+)?(?:on|for|in)\\s+(.+)$/i\n  );\n  if (quorum) {\n    const [, rawCount, rawTarget] = quorum;\n    const { step, clarify } = resolveStep(policy, group(rawTarget));\n    if (!step) return clarify;\n    return proposeEdits(\n      policy,\n      [{ op: \"set-mode\", stepId: step.id, mode: \"quorum\", quorum: Number(rawCount) }],\n      `Require ${group(rawCount)} of ${step.approvers.length} approvers on \"${step.label}\".`\n    );\n  }\n\n  // \"Add Maria Chen (Controller) to Manager review\"\n  const add = text.match(/add\\s+(.+?)(?:\\s*\\(([^)]+)\\))?\\s+to\\s+(?:the\\s+)?(.+)$/i);\n  if (add) {\n    const [, rawName, rawTitle, rawTarget] = add;\n    const { step, clarify } = resolveStep(policy, group(rawTarget));\n    if (!step) return clarify;\n    const name = group(rawName).trim();\n    const title = rawTitle?.trim() || \"Approver\";\n    return runOrExplain(\n      policy,\n      [{ op: \"add-approver\", stepId: step.id, approver: { name, title } }],\n      `Add ${name} to \"${step.label}\".`\n    );\n  }\n\n  // \"Remove Maria Chen from Finance review\"\n  const removalMatch = text.match(/remove\\s+(.+?)\\s+from\\s+(?:the\\s+)?(.+)$/i);\n  if (removalMatch) {\n    const [, rawWho, rawTarget] = removalMatch;\n    const { step, clarify } = resolveStep(policy, group(rawTarget));\n    if (!step) return clarify;\n    const who = group(rawWho).trim();\n    const match = step.approvers.find(\n      (a) => a.name !== null && normalize(a.name).includes(normalize(who))\n    );\n    if (!match || match.name === null) {\n      return clarifyProposal(\n        policy,\n        `Nobody called \"${who}\" approves on \"${step.label}\".`,\n        step.approvers.flatMap((a) => (a.name === null ? [] : [a.name]))\n      );\n    }\n    return runOrExplain(\n      policy,\n      [{ op: \"remove-approver\", stepId: step.id, name: match.name }],\n      `Remove ${match.name} from \"${step.label}\".`\n    );\n  }\n\n  // \"Rename Manager review to Budget owner review\"\n  const rename = text.match(/rename\\s+(?:the\\s+)?(.+?)\\s+to\\s+(.+)$/i);\n  if (rename) {\n    const [, rawFrom, rawTo] = rename;\n    const { step, clarify } = resolveStep(policy, group(rawFrom));\n    if (!step) return clarify;\n    return proposeEdits(\n      policy,\n      [{ op: \"rename-step\", stepId: step.id, label: group(rawTo).trim() }],\n      `Rename \"${step.label}\".`\n    );\n  }\n\n  // \"Remove the Director sign-off\"\n  const remove = text.match(/(?:remove|delete)\\s+(?:the\\s+)?(.+)$/i);\n  if (remove) {\n    const [, rawTarget] = remove;\n    const { step, clarify } = resolveStep(policy, group(rawTarget));\n    if (!step) return clarify;\n    return proposeEdits(\n      policy,\n      [{ op: \"remove-step\", stepId: step.id }],\n      `Remove \"${step.label}\" and rewire around it.`\n    );\n  }\n\n  return clarifyProposal(\n    policy,\n    \"The demo parser understands thresholds, approvers, quorums, renames, and removals.\",\n    [...demoInstructions]\n  );\n};\n\nexport const demoProposer: Proposer = async (instruction, policy) => {\n  return await Promise.resolve(proposeDemoEdit(instruction, policy));\n};\n\nconst runOrExplain = (policy: ApprovalPolicy, ops: EditOp[], reason: string): EditProposal => {\n  try {\n    return proposeEdits(policy, ops, reason);\n  } catch (error) {\n    return clarifyProposal(policy, error instanceof Error ? error.message : String(error));\n  }\n};\n",
      "type": "registry:lib",
      "target": "lib/approvals-ui/demo-proposer.ts"
    }
  ],
  "type": "registry:component"
}