{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "approval-card",
  "type": "registry:component",
  "title": "Approval card",
  "description": "Human-in-the-loop questions the agent asks before acting.",
  "categories": [
    "ai-interface"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/button.json",
    "https://irisui.dev/r/glide-menu.json"
  ],
  "files": [
    {
      "path": "src/registry/components/approval-card/approval-card.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\nimport { Button } from \"../button/button\";\nimport GlideMenu from \"../glide-menu/glide-menu\";\n\n/* ─────────────────────────────────────────────────────────\n * APPROVAL CARD (human-in-the-loop)\n * One question at a time. The stack slides vertically as you\n * move between questions (the card's height animates to fit),\n * the step counter rolls like an odometer, and the footer has\n * a quiet Skip and an accent Continue (Enter in the free-text row).\n * Single-choice answers auto-advance; multi-select waits.\n * ───────────────────────────────────────────────────────── */\n\nexport type ApprovalQuestion = {\n  q: string;\n  type: \"radio\" | \"check\";\n  options: string[];\n};\n\nconst QUESTIONS: ApprovalQuestion[] = [\n  {\n    q: \"Which story should I draft first?\",\n    type: \"radio\",\n    options: [\"Greek bond yields\", \"ECB rate signals\", \"Ferry strike\"],\n  },\n  {\n    q: \"Which formats should I compose?\",\n    type: \"check\",\n    options: [\"Quick brief\", \"Full story\", \"Digest block\"],\n  },\n  {\n    q: \"Who signs off before it runs?\",\n    type: \"radio\",\n    options: [\"Desk editor\", \"Managing editor\", \"Editor-in-chief\"],\n  },\n];\n\nexport type ApprovalLabels = {\n  skip: string;\n  continue: string;\n  send: string;\n  customPlaceholder: string;\n  sentMessage: string;\n};\n\nconst DEFAULT_LABELS: ApprovalLabels = {\n  skip: \"Skip\",\n  continue: \"Continue\",\n  send: \"Send\",\n  customPlaceholder: \"Something else…\",\n  sentMessage: \"Answers sent\",\n};\n\nconst ROLL_MS = 400;\nconst SLIDE = \"360ms cubic-bezier(0.22, 1, 0.36, 1)\";\n\n/* odometer digits — each character that changes rolls up (or down) */\nfunction RollingDigits({ value }: { value: string }) {\n  const prevRef = useRef(value);\n  const [oldVal, setOldVal] = useState(value);\n  const [newVal, setNewVal] = useState(value);\n  const [rolling, setRolling] = useState(false);\n  const [shifted, setShifted] = useState(false);\n  const [dir, setDir] = useState<\"up\" | \"down\">(\"up\");\n\n  useEffect(() => {\n    if (prevRef.current === value) return undefined;\n    const from = prevRef.current;\n    prevRef.current = value;\n    const fromN = parseInt(from, 10);\n    const toN = parseInt(value, 10);\n    setDir(Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN ? \"down\" : \"up\");\n    setOldVal(from);\n    setNewVal(value);\n    setRolling(true);\n    setShifted(false);\n\n    let raf2 = 0;\n    const raf1 = requestAnimationFrame(() => {\n      raf2 = requestAnimationFrame(() => setShifted(true));\n    });\n    const done = setTimeout(() => {\n      setRolling(false);\n      setOldVal(value);\n      setShifted(false);\n    }, ROLL_MS);\n\n    return () => {\n      cancelAnimationFrame(raf1);\n      cancelAnimationFrame(raf2);\n      clearTimeout(done);\n    };\n  }, [value]);\n\n  const chars = rolling ? newVal : oldVal;\n\n  return (\n    <>\n      {Array.from({ length: chars.length }, (_, i) => {\n        const o = oldVal[i] ?? \"\";\n        const n = chars[i] ?? \"\";\n        if (!rolling || o === n) {\n          return <span key={`${i}-${n}`}>{n}</span>;\n        }\n        const top = dir === \"down\" ? n : o;\n        const bottom = dir === \"down\" ? o : n;\n        const restY = dir === \"down\" ? \"0\" : \"-1em\";\n        const startY = dir === \"down\" ? \"-1em\" : \"0\";\n        return (\n          <span\n            key={`${i}-${o}-${n}-${dir}`}\n            style={{\n              display: \"inline-block\",\n              position: \"relative\",\n              overflow: \"hidden\",\n              height: \"1em\",\n              lineHeight: \"1em\",\n              verticalAlign: \"-0.05em\",\n            }}\n          >\n            <span\n              style={{\n                display: \"flex\",\n                flexDirection: \"column\",\n                transition: \"transform 350ms cubic-bezier(0.4, 0, 0.2, 1)\",\n                transform: `translateY(${shifted ? restY : startY})`,\n              }}\n            >\n              <span style={{ height: \"1em\", lineHeight: \"1em\" }}>{top}</span>\n              <span style={{ height: \"1em\", lineHeight: \"1em\" }}>{bottom}</span>\n            </span>\n          </span>\n        );\n      })}\n    </>\n  );\n}\n\nfunction Ico({ path, size = 14, sw = 2 }: { path: React.ReactNode; size?: number; sw?: number }) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={sw}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      {path}\n    </svg>\n  );\n}\n\nexport default function ApprovalCard({\n  questions = QUESTIONS,\n  labels,\n  onSubmitted,\n  onAnswerChange,\n  resettable = true,\n}: {\n  questions?: ApprovalQuestion[];\n  labels?: Partial<ApprovalLabels>;\n  onSubmitted?: (answers: Record<number, number[]>) => void;\n  onAnswerChange?: (questionIndex: number, answer: number[]) => void;\n  resettable?: boolean;\n  variant?: string;\n} = {}) {\n  const t = { ...DEFAULT_LABELS, ...labels };\n  const [qi, setQi] = useState(0);\n  const [answers, setAnswers] = useState<Record<number, number[]>>({});\n  const [custom, setCustom] = useState<Record<number, string>>({});\n  const [sent, setSent] = useState(false);\n  const [open, setOpen] = useState(true);\n\n  const advanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const questionRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const measured = useRef(false);\n  const [viewportH, setViewportH] = useState<number | undefined>(undefined);\n  const [trackY, setTrackY] = useState(0);\n  const [animate, setAnimate] = useState(false);\n  // Until the first question is measured, render only the active one so the\n  // initial (and SSR) height is Q1's height — not all questions stacked, which\n  // would flash to full height and then shrink on mount.\n  const [ready, setReady] = useState(false);\n\n  const last = qi === questions.length - 1;\n  const selected = answers[qi] ?? [];\n  const hasAnswer = selected.length > 0 || Boolean(custom[qi]?.trim());\n\n  const sync = (withAnim: boolean) => {\n    const item = questionRefs.current[qi];\n    if (!item) return;\n    const reduce = typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    setViewportH(item.offsetHeight);\n    setTrackY(item.offsetTop);\n    setAnimate(withAnim && !reduce);\n  };\n\n  useLayoutEffect(() => {\n    const withAnim = measured.current;\n    measured.current = true;\n    sync(withAnim);\n    setReady(true);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [qi, answers, custom, open, sent]);\n\n  useEffect(() => {\n    const id = requestAnimationFrame(() => sync(measured.current));\n    return () => cancelAnimationFrame(id);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [qi]);\n\n  useEffect(\n    () => () => {\n      if (advanceTimer.current) clearTimeout(advanceTimer.current);\n    },\n    [],\n  );\n\n  const goTo = (next: number) => {\n    if (advanceTimer.current) clearTimeout(advanceTimer.current);\n    setQi(Math.min(Math.max(next, 0), questions.length - 1));\n  };\n\n  const send = () => {\n    if (advanceTimer.current) clearTimeout(advanceTimer.current);\n    setSent(true);\n    onSubmitted?.(answers);\n  };\n\n  const advance = () => {\n    if (last) send();\n    else goTo(qi + 1);\n  };\n\n  const toggle = (index: number) => {\n    const type = questions[qi].type;\n    setAnswers((current) => {\n      const picked = current[qi] ?? [];\n      const next =\n        type === \"radio\"\n          ? [index]\n          : picked.includes(index)\n            ? picked.filter((item) => item !== index)\n            : [...picked, index];\n      onAnswerChange?.(qi, next);\n      return { ...current, [qi]: next };\n    });\n    if (type === \"radio\") {\n      setCustom((current) => ({ ...current, [qi]: \"\" }));\n      if (advanceTimer.current) clearTimeout(advanceTimer.current);\n      advanceTimer.current = setTimeout(() => {\n        if (last) send();\n        else setQi((current) => Math.min(questions.length - 1, current + 1));\n      }, 480);\n    }\n  };\n\n  const reset = () => {\n    setQi(0);\n    setAnswers({});\n    setCustom({});\n    setSent(false);\n    setOpen(true);\n    measured.current = false;\n  };\n\n  if (!open) {\n    return (\n      <button\n        type=\"button\"\n        onClick={() => setOpen(true)}\n        className=\"rounded-button bg-surface px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover\"\n      >\n        Open approval\n      </button>\n    );\n  }\n\n  if (sent) {\n    return (\n      <div\n        className=\"flex w-full max-w-80 items-center gap-3\"\n        style={{ animation: \"pop-in 260ms cubic-bezier(0.23,1,0.32,1) both\" }}\n      >\n        <span className=\"inline-flex items-center gap-1.5 rounded-full bg-green-tint py-1 pr-2.5 pl-1 text-[12.5px] font-medium text-green\">\n          <span className=\"flex size-4.5 items-center justify-center rounded-full bg-green text-white\">\n            <svg\n              width=\"11\"\n              height=\"11\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"3\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <path d=\"M20 6L9 17l-5-5\" />\n            </svg>\n          </span>\n          {t.sentMessage}\n        </span>\n        {resettable && (\n          <button\n            type=\"button\"\n            onClick={reset}\n            className=\"text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink\"\n          >\n            Start over\n          </button>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"w-full max-w-80\">\n      <div\n        className=\"relative overflow-hidden rounded-card bg-surface shadow-card\"\n        style={{ animation: \"fade-up 380ms cubic-bezier(0.23,1,0.32,1) both\" }}\n      >\n        <button\n          type=\"button\"\n          aria-label=\"Dismiss\"\n          onClick={() => setOpen(false)}\n          className=\"inline-flex size-7 items-center justify-center rounded-button absolute right-2.5 top-2.5 z-10 text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink\"\n        >\n          <Ico size={14} sw={2.2} path={<path d=\"M18 6L6 18M6 6l12 12\" />} />\n        </button>\n        <div className=\"p-3\">\n          {/* the question itself is the heading */}\n          <div\n            className=\"overflow-hidden\"\n            style={{ height: viewportH, transition: animate ? `height ${SLIDE}` : undefined }}\n            aria-live=\"polite\"\n          >\n            <div\n              style={{\n                display: \"flex\",\n                flexDirection: \"column\",\n                gap: 26,\n                transform: `translate3d(0, ${-trackY}px, 0)`,\n                transition: animate ? `transform ${SLIDE}` : undefined,\n                willChange: \"transform\",\n              }}\n            >\n              {questions.map((question, qIdx) => {\n                const active = qIdx === qi;\n                // Before the first measure, mount only the active question so the\n                // card opens at its real height instead of flashing to full height.\n                if (!ready && !active) return null;\n                const picked = answers[qIdx] ?? [];\n                const questionStyle: CSSProperties = {\n                  opacity: active ? 1 : 0,\n                  transition: animate ? `opacity ${SLIDE}` : undefined,\n                  pointerEvents: active ? undefined : \"none\",\n                };\n                return (\n                  <div\n                    key={qIdx}\n                    ref={(el) => {\n                      questionRefs.current[qIdx] = el;\n                    }}\n                    aria-hidden={active ? undefined : true}\n                    style={questionStyle}\n                  >\n                    <div className=\"pr-7 text-[14px] font-medium text-ink\">{question.q}</div>\n                    <GlideMenu\n                      className=\"mt-2.5 flex flex-col gap-1\"\n                      highlightClassName=\"inset-x-0 rounded-control bg-hover\"\n                    >\n                      {question.options.map((option, i) => {\n                        const on = picked.includes(i);\n                        return (\n                          <button\n                            key={option}\n                            type=\"button\"\n                            data-menu-row\n                            aria-pressed={on}\n                            tabIndex={active ? 0 : -1}\n                            onClick={() => {\n                              if (active) toggle(i);\n                            }}\n                            className=\"relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 text-left transition-colors duration-100\"\n                          >\n                            <span\n                              className={`flex size-4 shrink-0 items-center justify-center transition-colors duration-200\n                                ${question.type === \"radio\" ? \"rounded-full\" : \"rounded-[5px]\"}\n                                ${on ? \"bg-ink text-canvas\" : \"shadow-[inset_0_0_0_1.5px_var(--line-strong)] text-transparent\"}`}\n                            >\n                              {question.type === \"radio\" ? (\n                                <span\n                                  className=\"size-1.5 rounded-full bg-canvas transition-transform duration-200\"\n                                  style={{ transform: on ? \"scale(1)\" : \"scale(0)\" }}\n                                />\n                              ) : (\n                                <svg\n                                  width=\"12\"\n                                  height=\"12\"\n                                  viewBox=\"0 0 24 24\"\n                                  fill=\"none\"\n                                  stroke=\"currentColor\"\n                                  strokeWidth=\"3\"\n                                  strokeLinecap=\"round\"\n                                  strokeLinejoin=\"round\"\n                                >\n                                  <path d=\"M20 6L9 17l-5-5\" />\n                                </svg>\n                              )}\n                            </span>\n                            <span\n                              className={`text-[13px] leading-none transition-colors duration-200 ${on ? \"text-ink\" : \"text-ink-2\"}`}\n                            >\n                              {option}\n                            </span>\n                          </button>\n                        );\n                      })}\n                      <label\n                        data-menu-row\n                        className=\"relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100\"\n                      >\n                        <input\n                          value={custom[qIdx] ?? \"\"}\n                          tabIndex={active ? 0 : -1}\n                          onChange={(event) => {\n                            if (!active) return;\n                            setCustom((current) => ({ ...current, [qIdx]: event.target.value }));\n                            if (question.type === \"radio\") setAnswers((current) => ({ ...current, [qIdx]: [] }));\n                          }}\n                          onKeyDown={(event) => {\n                            if (event.key === \"Enter\" && hasAnswer) {\n                              event.preventDefault();\n                              advance();\n                            }\n                          }}\n                          placeholder={t.customPlaceholder}\n                          aria-label=\"Custom answer\"\n                          className=\"min-w-0 flex-1 bg-transparent pl-1.5 text-[13px] text-ink outline-none placeholder:text-ink-3\"\n                        />\n                      </label>\n                    </GlideMenu>\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n        </div>\n\n        {/* footer — step nav (rolling counter) + pill actions */}\n        <div className=\"flex items-center justify-between gap-3 p-2.5\">\n          <div className=\"flex items-center gap-1 text-ink-3\">\n            <button\n              type=\"button\"\n              aria-label=\"Previous question\"\n              disabled={qi <= 0}\n              onClick={() => goTo(qi - 1)}\n              className=\"flex size-[18px] items-center justify-center rounded-button transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30\"\n            >\n              <Ico size={14} path={<path d=\"M18 15l-6-6-6 6\" />} />\n            </button>\n            <span\n              className=\"inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3\"\n              style={{ letterSpacing: \"-0.1px\", lineHeight: 1 }}\n            >\n              <RollingDigits value={`${qi + 1} / ${questions.length}`} />\n            </span>\n            <button\n              type=\"button\"\n              aria-label=\"Next question\"\n              disabled={last}\n              onClick={() => goTo(qi + 1)}\n              className=\"flex size-[18px] items-center justify-center rounded-button transition-colors duration-100 enabled:hover:text-ink disabled:opacity-30\"\n            >\n              <Ico size={14} path={<path d=\"M6 9l6 6 6-6\" />} />\n            </button>\n          </div>\n\n          <div className=\"-mr-0.5 flex items-center gap-1.5\">\n            <Button variant=\"ghost\" size=\"sm\" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>\n              {t.skip}\n            </Button>\n            <Button variant=\"accent\" size=\"sm\" disabled={!hasAnswer} onClick={advance}>\n              {last ? t.send : t.continue}\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/approval-card/approval-card.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/approval-card",
  "meta": {
    "kind": "component",
    "group": "AI interface",
    "export": "ApprovalCard",
    "docs": "https://irisui.dev/components/approval-card"
  }
}
