{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"approval-card","title":"Approval Card","description":"Human-in-the-loop questions the agent asks before acting.","dependencies":[],"registryDependencies":["https://ward.so/r/foundation.json","https://ward.so/r/button.json","https://ward.so/r/glide-menu.json"],"files":[{"path":"registry/ward/ApprovalCard.tsx","content":"\"use client\";\n/*\n * MIT License\n *\n * Copyright (c) 2026 Shane Levine\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport \"./styles/foundation.css\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\nimport { Button } from \"@/registry/ward/ui/button\";\nimport GlideMenu from \"./GlideMenu\";\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 uses\n * pill actions — a quiet Skip and a dark Continue with a ⏎.\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: \"How many projects should we launch?\",\n    type: \"radio\",\n    options: [\"Three (core line)\", \"Five (full case)\", \"Just one hero\"],\n  },\n  {\n    q: \"Which mix-ins should we stock?\",\n    type: \"check\",\n    options: [\"Chocolate chips\", \"Design bits\", \"Sprinkles\"],\n  },\n  {\n    q: \"Which market do we enter first?\",\n    type: \"radio\",\n    options: [\"Food trucks\", \"Grocery freezers\", \"Work shops\"],\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;\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={{ display: \"inline-block\", position: \"relative\", overflow: \"hidden\", height: \"1em\", lineHeight: \"1em\", verticalAlign: \"-0.05em\" }}\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 width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={sw} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\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    // Measurement must update before paint to avoid a full-height flash.\n    // eslint-disable-next-line react-hooks/set-state-in-effect\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(() => () => { if (advanceTimer.current) clearTimeout(advanceTimer.current); }, []);\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 = 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 type=\"button\" onClick={() => setOpen(true)} className=\"rounded-control bg-card px-3 py-2 text-[12.5px] font-medium text-ink shadow-btn transition-colors duration-150 hover:bg-hover\">\n        Open approval\n      </button>\n    );\n  }\n\n  if (sent) {\n    return (\n      <div className=\"flex w-full max-w-80 items-center gap-3\" style={{ animation: \"pop-in 260ms cubic-bezier(0.23,1,0.32,1) both\" }}>\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 width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M20 6L9 17l-5-5\" /></svg>\n          </span>\n          {t.sentMessage}\n        </span>\n        {resettable && (\n          <button type=\"button\" onClick={reset} className=\"text-[12px] font-medium text-ink-3 transition-colors duration-150 hover:text-ink\">\n            Start over\n          </button>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"w-full max-w-80\">\n      <div className=\"relative overflow-hidden rounded-card bg-card shadow-card\" style={{ animation: \"fade-up 380ms cubic-bezier(0.23,1,0.32,1) both\" }}>\n        <button\n          type=\"button\"\n          aria-label=\"Dismiss\"\n          onClick={() => setOpen(false)}\n          className=\"primitive-icon-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=\"primitive-card-pad\">\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) => { questionRefs.current[qIdx] = el; }}\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 className=\"mt-2.5 flex flex-col gap-1\" highlightClassName=\"inset-x-0 rounded-control bg-hover\">\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={() => { if (active) toggle(i); }}\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 className=\"size-1.5 rounded-full bg-canvas transition-transform duration-200\" style={{ transform: on ? \"scale(1)\" : \"scale(0)\" }} />\n                              ) : (\n                                <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"round\" strokeLinejoin=\"round\"><path d=\"M20 6L9 17l-5-5\" /></svg>\n                              )}\n                            </span>\n                            <span className={`text-[13px] leading-none transition-colors duration-200 ${on ? \"text-ink\" : \"text-ink-2\"}`}>\n                              {option}\n                            </span>\n                          </button>\n                        );\n                      })}\n                      <label data-menu-row className=\"relative z-10 flex items-center gap-1.5 rounded-control pl-1 pr-2 py-1 transition-colors duration-100\">\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=\"primitive-card-footer flex items-center justify-between gap-3\">\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-[5px] 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 className=\"inline-flex items-center text-[12px] font-medium tabular-nums text-ink-3\" style={{ letterSpacing: \"-0.1px\", lineHeight: 1 }}>\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-[5px] 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=\"secondary\" size=\"sm\" className=\"h-7 text-[13px]\" onClick={() => (last ? setOpen(false) : goTo(qi + 1))}>\n              {t.skip}\n            </Button>\n            <Button size=\"sm\" className=\"h-7 text-[13px]\" 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/ward/ApprovalCard.tsx"}],"meta":{"variants":["Default"],"version":"1.1.0","source":"https://github.com/slev12397/beautiful-ui/blob/44a274e598395ab61e7c96c26fda2758780253b7/components/primitives/ApprovalCard.tsx","access":"free"},"categories":["ai"],"type":"registry:component"}