{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "alert-rules",
  "type": "registry:component",
  "title": "Alert Rules",
  "description": "An alert you write as a sentence: when error rate is above 2% for at least 5 minutes, only for checkout in production, page the on-call. A 30-day timeline shows which past incidents it would have caught, and warns you before it gets noisy.",
  "categories": [
    "component",
    "Filters"
  ],
  "dependencies": [
    "@qnot/core",
    "@qnot/react",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/input.json",
    "https://coss.com/ui/r/select.json",
    "https://coss.com/ui/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/devl/alert-rules.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  type AnyFieldDef,\n  compile,\n  type EditorSpec,\n  type Fields,\n  fieldLabel,\n  formatInput,\n  formatList,\n  type Issue,\n  parseInput,\n  parseList,\n  type Query,\n  type QueryNode,\n  type QueryOf,\n  validateQuery,\n} from \"@qnot/core\";\nimport { QueryProvider, useGroup, useQuery, useQueryStore, useRule } from \"@qnot/react\";\nimport { BellRingIcon, PlusIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { memo, type ReactNode, useMemo, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PastIncident {\n  id: string;\n  title: string;\n  day: number;\n  at: string;\n  [attribute: string]: unknown;\n}\n\nexport interface AlertAction {\n  id: string;\n  name: string;\n}\n\nexport interface AlertTrigger {\n  metric: string;\n  operator: \"gt\" | \"gte\" | \"lt\" | \"lte\";\n  threshold: number | null;\n  minutes: number | null;\n}\n\nexport interface AlertRulesProps {\n  fields: Fields;\n  metrics: string[];\n  durationField: string;\n  incidents: readonly PastIncident[];\n  actions: AlertAction[];\n  defaultName?: string;\n  defaultTrigger: AlertTrigger;\n  defaultScope?: Query;\n  defaultAction?: string;\n  days?: number;\n  className?: string;\n}\n\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst triggerOps = [\n  { value: \"gt\", label: \"is above\" },\n  { value: \"gte\", label: \"reaches\" },\n  { value: \"lt\", label: \"drops below\" },\n  { value: \"lte\", label: \"is at most\" },\n];\n\nexport function AlertRules({\n  fields,\n  metrics,\n  durationField,\n  incidents,\n  actions,\n  defaultName = \"Checkout errors\",\n  defaultTrigger,\n  defaultScope = { kind: \"group\", combinator: \"and\", children: [] },\n  defaultAction,\n  days = 30,\n  className,\n}: AlertRulesProps) {\n  const scopeFields = useMemo(\n    () => Object.fromEntries(Object.entries(fields).filter(([k]) => !metrics.includes(k) && k !== durationField)) as Fields,\n    [fields, metrics, durationField],\n  );\n  const store = useQueryStore({ fields: scopeFields, initial: defaultScope as QueryOf<Fields> });\n  return (\n    <QueryProvider store={store}>\n      <AlertEditor\n        actions={actions}\n        className={className}\n        days={days}\n        defaultAction={defaultAction ?? actions[0].id}\n        defaultName={defaultName}\n        defaultTrigger={defaultTrigger}\n        durationField={durationField}\n        fields={fields}\n        incidents={incidents}\n        metrics={metrics}\n        scopeRoot={store.rootId}\n      />\n    </QueryProvider>\n  );\n}\n\nfunction AlertEditor({\n  fields,\n  metrics,\n  durationField,\n  incidents,\n  actions,\n  defaultName,\n  defaultTrigger,\n  defaultAction,\n  days,\n  scopeRoot,\n  className,\n}: Omit<AlertRulesProps, \"defaultScope\" | \"defaultAction\" | \"defaultName\" | \"days\"> & {\n  defaultName: string;\n  defaultAction: string;\n  days: number;\n  scopeRoot: string;\n}) {\n  const reduce = useReducedMotion();\n  const scope = useQuery();\n  const [name, setName] = useState(defaultName);\n  const [enabled, setEnabled] = useState(true);\n  const [trigger, setTrigger] = useState(defaultTrigger);\n  const [action, setAction] = useState(defaultAction);\n  const [unreadable, setUnreadable] = useState<Record<number, boolean>>({});\n  const metricDef = fields[trigger.metric];\n\n  const query = useMemo<Query>(() => {\n    const children: QueryNode[] = [\n      { kind: \"rule\", field: trigger.metric, operator: trigger.operator, value: trigger.threshold },\n      { kind: \"rule\", field: durationField, operator: \"gte\", value: trigger.minutes },\n    ];\n    if (scope.combinator === \"and\" && !scope.not) children.push(...scope.children);\n    else if (scope.children.length) children.push(scope);\n    return { kind: \"group\", combinator: \"and\", children };\n  }, [trigger, durationField, scope]);\n\n  const issues = useMemo(() => validateQuery(fields, query), [fields, query]);\n  const triggerIssues = issues.filter((i) => (i.path[0] === 0 || i.path[0] === 1) && i.severity === \"error\");\n  const blocked = issues.some((i) => i.severity === \"error\");\n  const fired = useMemo(\n    () => (blocked ? [] : incidents.filter(compile(fields, query as QueryOf<Fields>) as (i: PastIncident) => boolean)),\n    [blocked, incidents, fields, query],\n  );\n\n  const setT = (patch: Partial<AlertTrigger>) => setTrigger((t) => ({ ...t, ...patch }));\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full min-w-0 max-w-[600px] flex-col overflow-hidden rounded-2xl border bg-card text-sm shadow-xs/5\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-3 px-4 pt-3.5 pb-1\">\n        <BellRingIcon className=\"size-4 shrink-0 text-muted-foreground\" />\n        <input\n          aria-label=\"Alert name\"\n          className=\"-mx-1.5 min-w-0 flex-1 rounded-md bg-transparent px-1.5 py-0.5 font-medium outline-none transition-colors hover:bg-accent/50 focus-visible:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring\"\n          onChange={(e) => setName(e.target.value)}\n          value={name}\n        />\n        <Switch aria-label=\"Alert enabled\" checked={enabled} onCheckedChange={setEnabled} />\n      </div>\n\n      <div className={cn(\"flex flex-col gap-3 px-4 pt-2 pb-4 transition-opacity duration-200\", !enabled && \"opacity-56\")}>\n        <div className=\"flex flex-col gap-1.5\">\n          <span className=\"font-medium text-muted-foreground text-xs\">When</span>\n          <div className=\"flex flex-wrap items-center gap-x-1.5 gap-y-2 leading-8\">\n            <Select\n              items={metrics.map((m) => ({ value: m, label: fieldLabel(m, fields[m]) }))}\n              onValueChange={(v) => v && setT({ metric: v })}\n              value={trigger.metric}\n            >\n              <SelectTrigger aria-label=\"Metric\" className=\"w-auto min-w-0\" size=\"sm\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectPopup>\n                {metrics.map((m) => (\n                  <SelectItem key={m} value={m}>\n                    {fieldLabel(m, fields[m])}\n                  </SelectItem>\n                ))}\n              </SelectPopup>\n            </Select>\n            <Select items={triggerOps} onValueChange={(v) => v && setT({ operator: v as AlertTrigger[\"operator\"] })} value={trigger.operator}>\n              <SelectTrigger aria-label=\"Comparison\" className=\"w-auto min-w-0\" size=\"sm\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectPopup>\n                {triggerOps.map((o) => (\n                  <SelectItem key={o.value} value={o.value}>\n                    {o.label}\n                  </SelectItem>\n                ))}\n              </SelectPopup>\n            </Select>\n            <NumberSlot\n              invalid={triggerIssues.some((i) => i.path[0] === 0)}\n              label=\"Threshold\"\n              onChange={(threshold) => setT({ threshold })}\n              onUnreadable={(bad) => setUnreadable((u) => ({ ...u, 0: bad }))}\n              suffix={metricDef?.suffix}\n              value={trigger.threshold}\n            />\n            <span className=\"text-muted-foreground\">for at least</span>\n            <NumberSlot\n              invalid={triggerIssues.some((i) => i.path[0] === 1)}\n              label=\"Minutes\"\n              onChange={(minutes) => setT({ minutes })}\n              onUnreadable={(bad) => setUnreadable((u) => ({ ...u, 1: bad }))}\n              suffix=\"min\"\n              value={trigger.minutes}\n            />\n          </div>\n          <AnimatePresence initial={false}>\n            {triggerIssues.length > 0 && (\n              <motion.p\n                animate={{ opacity: 1, height: \"auto\" }}\n                className=\"overflow-hidden text-destructive-foreground text-xs\"\n                exit={{ opacity: 0, height: 0 }}\n                initial={{ opacity: 0, height: 0 }}\n                transition={{ duration: reduce ? 0 : 0.18, ease: easeOut }}\n              >\n                {triggerIssues\n                  .map(\n                    (i) =>\n                      `${i.path[0] === 0 ? \"Threshold\" : \"Duration\"}: ${unreadable[i.path[0] ?? -1] ? \"enter a number\" : i.message.toLowerCase()}`,\n                  )\n                  .join(\" · \")}\n              </motion.p>\n            )}\n          </AnimatePresence>\n        </div>\n\n        <div className=\"flex flex-col gap-1.5\">\n          <span className=\"font-medium text-muted-foreground text-xs\">Scope</span>\n          <Conditions id={scopeRoot} status={null} />\n        </div>\n\n        <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1.5\">\n          <span className=\"font-medium text-muted-foreground text-xs\">Then</span>\n          <Select items={actions.map((a) => ({ value: a.id, label: a.name }))} onValueChange={(v) => v && setAction(v)} value={action}>\n            <SelectTrigger aria-label=\"Action\" className=\"w-auto min-w-0\" size=\"sm\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectPopup>\n              {actions.map((a) => (\n                <SelectItem key={a.id} value={a.id}>\n                  {a.name}\n                </SelectItem>\n              ))}\n            </SelectPopup>\n          </Select>\n        </div>\n      </div>\n\n      <Preview blocked={blocked} days={days} fired={fired} incidents={incidents} metric={trigger.metric} metricDef={metricDef} />\n    </div>\n  );\n}\n\nfunction NumberSlot({\n  value,\n  suffix,\n  label,\n  invalid,\n  onChange,\n  onUnreadable,\n}: {\n  value: number | null;\n  suffix?: string;\n  label: string;\n  invalid: boolean;\n  onChange: (value: number | null) => void;\n  onUnreadable: (unreadable: boolean) => void;\n}) {\n  const [draft, setDraft] = useState<string | null>(null);\n  return (\n    <span className=\"relative inline-flex w-20\">\n      <Input\n        aria-invalid={invalid || undefined}\n        aria-label={label}\n        className={cn(\"tabular-nums\", suffix && \"[&_input]:pe-9\")}\n        inputMode=\"decimal\"\n        onBlur={() => setDraft(null)}\n        onChange={(e) => {\n          setDraft(e.target.value);\n          const n = e.target.value.trim() === \"\" ? null : Number(e.target.value);\n          onUnreadable(n !== null && Number.isNaN(n));\n          onChange(n === null || Number.isNaN(n) ? null : n);\n        }}\n        onFocus={() => setDraft(formatInput(value))}\n        size=\"sm\"\n        value={draft ?? formatInput(value)}\n      />\n      {suffix && (\n        <span className=\"pointer-events-none absolute inset-y-0 end-2.5 flex items-center text-muted-foreground text-xs\">{suffix}</span>\n      )}\n    </span>\n  );\n}\n\nfunction Preview({\n  incidents,\n  fired,\n  days,\n  blocked,\n  metric,\n  metricDef,\n}: {\n  incidents: readonly PastIncident[];\n  fired: PastIncident[];\n  days: number;\n  blocked: boolean;\n  metric: string;\n  metricDef: AnyFieldDef | undefined;\n}) {\n  const reduce = useReducedMotion();\n  const firedIds = new Set(fired.map((i) => i.id));\n  const perWeek = (fired.length / days) * 7;\n  const verdict = blocked\n    ? \"Fix the trigger to preview it\"\n    : fired.length === 0\n      ? `Quiet: it wouldn't have fired in ${days} days`\n      : `Would have fired ${fired.length} ${fired.length === 1 ? \"time\" : \"times\"} in ${days} days`;\n  const noisy = perWeek > 2;\n\n  return (\n    <div className=\"border-t bg-muted/32 px-4 pt-3 pb-3.5 dark:bg-muted/16\">\n      <div className=\"flex items-baseline justify-between gap-3\">\n        <h4 className=\"font-medium text-xs\">{verdict}</h4>\n        {noisy && !blocked && (\n          <span className=\"shrink-0 rounded-full bg-warning/12 px-2 py-0.5 font-medium text-[11px] text-warning-foreground\">\n            Noisy · {perWeek.toFixed(1)} a week\n          </span>\n        )}\n      </div>\n      <div aria-hidden className=\"relative mt-3 mb-1 h-8\">\n        <div className=\"absolute inset-x-0 top-1/2 h-px bg-border\" />\n        {Array.from({ length: Math.floor(days / 7) + 1 }, (_, w) => (\n          <span className=\"absolute top-1/2 h-2 w-px -translate-y-1/2 bg-border\" key={w} style={{ left: `${((w * 7) / days) * 100}%` }} />\n        ))}\n        {incidents.map((incident) => {\n          const hit = firedIds.has(incident.id);\n          return (\n            <motion.span\n              animate={{ scale: hit ? 1 : 0.6, opacity: hit ? 1 : 0.6 }}\n              className={cn(\n                \"absolute top-1/2 -mt-1.5 -ml-1.5 size-3 rounded-full border-2\",\n                hit ? \"border-card bg-destructive\" : \"border-muted-foreground/48 bg-card\",\n              )}\n              initial={false}\n              key={incident.id}\n              style={{ left: `${(incident.day / days) * 100}%` }}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", duration: 0.3, bounce: 0.2 }}\n            />\n          );\n        })}\n      </div>\n      <div className=\"flex justify-between text-[11px] text-muted-foreground tabular-nums\">\n        <span>{days} days ago</span>\n        <span>Today</span>\n      </div>\n      <ul\n        aria-label=\"Incidents that would have fired\"\n        className=\"mt-2.5 flex h-36 flex-col gap-0.5 overflow-y-auto rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        tabIndex={0}\n      >\n        <AnimatePresence initial={false}>\n          {fired.map((incident) => (\n            <motion.li\n              animate={{ opacity: 1 }}\n              className=\"flex h-7 shrink-0 items-center gap-2 text-xs\"\n              exit={{ opacity: 0, transition: { duration: 0 } }}\n              initial={{ opacity: 0 }}\n              key={incident.id}\n              transition={{ duration: reduce ? 0 : 0.15, ease: easeOut }}\n            >\n              <span className=\"size-1.5 shrink-0 rounded-full bg-destructive\" />\n              <span className=\"w-16 shrink-0 text-muted-foreground tabular-nums\">{incident.at}</span>\n              <span className=\"min-w-0 flex-1 truncate\">{incident.title}</span>\n              <span className=\"shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums\">\n                {String(incident[metric])}\n                {metricDef?.suffix === \"%\" ? \"%\" : ` ${metricDef?.suffix ?? \"\"}`}\n              </span>\n            </motion.li>\n          ))}\n        </AnimatePresence>\n        {fired.length === 0 && (\n          <li className=\"flex h-full items-center justify-center text-center text-muted-foreground text-xs\">\n            {blocked ? \"Nothing to preview yet.\" : `None of the ${incidents.length} past incidents cross this line.`}\n          </li>\n        )}\n      </ul>\n    </div>\n  );\n}\n\nfunction Conditions({ id, status }: { id: string; status: ReactNode }) {\n  const { group, addRule, setCombinator } = useGroup(id);\n  return (\n    <div className=\"@container flex flex-col gap-2.5 @sm:gap-1.5\">\n      {group.children.map((child, i) => (\n        <div className=\"flex items-start gap-1.5\" key={child}>\n          {i === 0 ? (\n            <span className=\"flex h-8 w-11 shrink-0 items-center text-muted-foreground text-xs sm:h-7\">Where</span>\n          ) : i === 1 ? (\n            <button\n              aria-label={`Match ${group.combinator === \"and\" ? \"any\" : \"all\"} conditions instead`}\n              className=\"flex h-8 w-11 shrink-0 sm:h-7 items-center justify-center rounded-lg border border-input bg-background font-medium text-xs shadow-xs/5 outline-none transition-[background-color,scale] duration-150 hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] dark:bg-input/32\"\n              onClick={() => setCombinator(group.combinator === \"and\" ? \"or\" : \"and\")}\n              type=\"button\"\n            >\n              {group.combinator}\n            </button>\n          ) : (\n            <span className=\"flex h-8 w-11 shrink-0 sm:h-7 items-center ps-1.5 text-muted-foreground text-xs\">{group.combinator}</span>\n          )}\n          <div className=\"min-w-0 flex-1\">\n            <Condition id={child} />\n          </div>\n        </div>\n      ))}\n      <div className={cn(\"flex items-center justify-between gap-2\", group.children.length > 0 && \"ps-12.5\")}>\n        <button\n          className=\"-ms-1.5 flex h-7 items-center gap-1 rounded-md px-1.5 text-muted-foreground text-xs outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n          onClick={() => addRule()}\n          type=\"button\"\n        >\n          <PlusIcon className=\"size-3.5\" />\n          {group.children.length ? \"Condition\" : \"Scope to a service, environment or region\"}\n        </button>\n        {status && <span className=\"min-w-0 truncate text-muted-foreground text-xs tabular-nums\">{status}</span>}\n      </div>\n    </div>\n  );\n}\n\nconst Condition = memo(function Condition({ id }: { id: string }) {\n  const { rule, def, editor, operators, fieldOptions, issues, setField, setOperator, setValue, remove } = useRule(id);\n  const field = typeof rule.field === \"string\" ? rule.field : \"\";\n  const label = fieldOptions.find((o) => o.value === field)?.label ?? field;\n  const errors = issues.filter((i) => i.severity === \"error\" && i.code !== \"required\");\n  return (\n    <div>\n      <div className=\"grid grid-cols-[minmax(0,1fr)_2rem] items-start gap-1.5 @sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2rem] @lg:grid-cols-[8.5rem_8rem_minmax(0,1fr)_2rem]\">\n        <Select items={fieldOptions} onValueChange={(v) => v && setField(v)} value={field}>\n          <SelectTrigger aria-label=\"Attribute\" className=\"col-start-1 row-start-1 min-w-0\" size=\"sm\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectPopup>\n            {fieldOptions.map((o) => (\n              <SelectItem key={o.value} value={o.value}>\n                {o.label}\n              </SelectItem>\n            ))}\n          </SelectPopup>\n        </Select>\n        <Select items={operators} onValueChange={(v) => v && setOperator(v)} value={rule.operator}>\n          <SelectTrigger\n            aria-label=\"Operator\"\n            className=\"col-start-1 row-start-2 min-w-0 @sm:col-start-2 @sm:row-start-1\"\n            size=\"sm\"\n          >\n            <SelectValue />\n          </SelectTrigger>\n          <SelectPopup>\n            {operators.map((o) => (\n              <SelectItem key={o.value} value={o.value}>\n                {o.label}\n              </SelectItem>\n            ))}\n          </SelectPopup>\n        </Select>\n        <div\n          className={cn(\n            \"col-start-1 row-start-3 min-w-0 @sm:col-span-2 @sm:row-start-2 @lg:col-span-1 @lg:col-start-3 @lg:row-start-1\",\n            editor.arity === \"none\" && \"hidden\",\n          )}\n        >\n          <ValueEditor def={def} editor={editor} issues={issues} label={label} onChange={setValue} value={rule.value} />\n        </div>\n        <Button\n          aria-label={`Remove condition on ${label}`}\n          className=\"col-start-2 row-start-1 @sm:col-start-3 @lg:col-start-4\"\n          onClick={remove}\n          size=\"icon-sm\"\n          variant=\"ghost\"\n        >\n          <XIcon />\n        </Button>\n      </div>\n      {errors.length > 0 && <p className=\"pt-1 text-destructive-foreground text-xs\">{errors.map((e) => e.message).join(\" · \")}</p>}\n    </div>\n  );\n});\n\nfunction ValueEditor({\n  editor,\n  def,\n  value,\n  issues,\n  label,\n  onChange,\n}: {\n  editor: EditorSpec;\n  def: AnyFieldDef | undefined;\n  value: unknown;\n  issues: readonly Issue[];\n  label: string;\n  onChange: (value: unknown) => void;\n}) {\n  const invalidAt = (index?: number) =>\n    issues.some((i) => i.severity === \"error\" && i.code !== \"required\" && (index === undefined || i.index === undefined || i.index === index));\n  if (editor.arity === \"none\") return null;\n  if (editor.arity === \"two\") {\n    const pair = Array.isArray(value) ? value : [null, null];\n    return (\n      <div className=\"flex items-center gap-1.5\">\n        <Scalar def={def} editor={editor} invalid={invalidAt(0)} label={`${label} from`} onChange={(v) => onChange([v, pair[1] ?? null])} value={pair[0]} />\n        <span className=\"shrink-0 text-muted-foreground text-xs\">and</span>\n        <Scalar def={def} editor={editor} invalid={invalidAt(1)} label={`${label} to`} onChange={(v) => onChange([pair[0] ?? null, v])} value={pair[1]} />\n      </div>\n    );\n  }\n  if (editor.type === \"multiselect\") {\n    const options = editor.options ?? [];\n    const selected = Array.isArray(value) ? value.map(String) : [];\n    return (\n      <Select\n        items={options.map((o) => ({ value: String(o.value), label: o.label }))}\n        multiple\n        onValueChange={(next) => onChange((next as string[]).map((raw) => parseInput(def, raw)))}\n        value={selected}\n      >\n        <SelectTrigger aria-invalid={invalidAt() || undefined} aria-label={`${label} values`} className=\"min-w-0\" size=\"sm\">\n          <SelectValue>\n            {(vals: string[]) =>\n              vals.length === 0 ? (\n                <span className=\"text-muted-foreground\">Choose…</span>\n              ) : (\n                vals.map((v) => options.find((o) => String(o.value) === v)?.label ?? v).join(\", \")\n              )\n            }\n          </SelectValue>\n        </SelectTrigger>\n        <SelectPopup>\n          {options.map((o) => (\n            <SelectItem key={String(o.value)} value={String(o.value)}>\n              {o.label}\n            </SelectItem>\n          ))}\n        </SelectPopup>\n      </Select>\n    );\n  }\n  if (editor.type === \"list\") {\n    return (\n      <DraftInput\n        format={formatList}\n        invalid={invalidAt()}\n        label={`${label} values`}\n        onChange={onChange}\n        parse={(raw) => parseList(def, raw)}\n        placeholder=\"Comma, separated\"\n        value={value}\n      />\n    );\n  }\n  return <Scalar def={def} editor={editor} invalid={invalidAt()} label={label} onChange={onChange} value={value} />;\n}\n\nfunction Scalar({\n  editor,\n  def,\n  value,\n  invalid,\n  label,\n  onChange,\n}: {\n  editor: EditorSpec;\n  def: AnyFieldDef | undefined;\n  value: unknown;\n  invalid: boolean;\n  label: string;\n  onChange: (value: unknown) => void;\n}) {\n  if (editor.type === \"select\" || editor.type === \"boolean\") {\n    const items =\n      editor.type === \"boolean\"\n        ? [\n            { value: \"true\", label: \"Yes\" },\n            { value: \"false\", label: \"No\" },\n          ]\n        : (editor.options ?? []).map((o) => ({ value: String(o.value), label: o.label }));\n    return (\n      <Select\n        items={items}\n        onValueChange={(raw) => raw !== null && onChange(parseInput(def, String(raw)))}\n        value={value === null || value === undefined ? null : String(value)}\n      >\n        <SelectTrigger aria-invalid={invalid || undefined} aria-label={label} className=\"min-w-0\" size=\"sm\">\n          <SelectValue placeholder=\"Choose…\" />\n        </SelectTrigger>\n        <SelectPopup>\n          {items.map((o) => (\n            <SelectItem key={o.value} value={o.value}>\n              {o.label}\n            </SelectItem>\n          ))}\n        </SelectPopup>\n      </Select>\n    );\n  }\n  return (\n    <DraftInput\n      format={formatInput}\n      inputMode={editor.type === \"number\" ? \"decimal\" : undefined}\n      invalid={invalid}\n      label={label}\n      onChange={onChange}\n      parse={(raw) => parseInput(def, raw)}\n      placeholder={editor.type === \"number\" ? \"0\" : \"Value\"}\n      type={editor.type === \"date\" ? \"date\" : \"text\"}\n      value={value}\n    />\n  );\n}\n\nfunction DraftInput({\n  value,\n  parse,\n  format,\n  invalid,\n  label,\n  placeholder,\n  inputMode,\n  type = \"text\",\n  onChange,\n}: {\n  value: unknown;\n  parse: (raw: string) => unknown;\n  format: (value: unknown) => string;\n  invalid: boolean;\n  label: string;\n  placeholder?: string;\n  inputMode?: \"decimal\";\n  type?: \"text\" | \"date\";\n  onChange: (value: unknown) => void;\n}) {\n  const [draft, setDraft] = useState<string | null>(null);\n  const unreadable = draft !== null && draft.trim() !== \"\" && parse(draft) === null;\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1\">\n      <Input\n        aria-invalid={invalid || unreadable || undefined}\n        aria-label={label}\n        className={type === \"date\" ? \"[color-scheme:light] dark:[color-scheme:dark]\" : undefined}\n        inputMode={inputMode}\n        onBlur={() => setDraft(null)}\n        onChange={(e) => {\n          setDraft(e.target.value);\n          onChange(parse(e.target.value));\n        }}\n        onFocus={() => setDraft(format(value))}\n        placeholder={placeholder}\n        size=\"sm\"\n        type={type}\n        value={draft ?? format(value)}\n      />\n      {unreadable && <p className=\"text-destructive-foreground text-xs\">Enter a number</p>}\n    </div>\n  );\n}\n\n"
    }
  ]
}