{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "nudge",
  "type": "registry:component",
  "title": "Nudge",
  "description": "One clear suggestion with an honest read on how sure it is, a couple of alternatives a tap away, and a short window to undo once you accept.",
  "categories": [
    "component",
    "Writing"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/nudge.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, LightbulbIcon, RotateCcwIcon, SparklesIcon, Undo2Icon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NudgeOption {\n  id: string;\n  label: string;\n  title: string;\n  detail?: string;\n  confidence: number;\n  reasons?: string[];\n  result?: string;\n}\n\nexport interface NudgeProps {\n  options: NudgeOption[];\n  undoMs?: number;\n  onAccept?: (option: NudgeOption) => void;\n  onUndo?: (option: NudgeOption) => void;\n  className?: string;\n}\n\nconst spring = { type: \"spring\", stiffness: 400, damping: 32 } as const;\nconst press = \"transition-[scale,background-color,box-shadow] duration-150 ease-out active:scale-[0.97]\";\n\nfunction level(c: number) {\n  if (c >= 0.8) return { label: \"High confidence\", bar: \"bg-success\", text: \"text-success-foreground\" };\n  if (c >= 0.55) return { label: \"Fair confidence\", bar: \"bg-warning\", text: \"text-warning-foreground\" };\n  return { label: \"Low confidence\", bar: \"bg-muted-foreground\", text: \"text-muted-foreground\" };\n}\n\nfunction RollingPercent({ value }: { value: number }) {\n  const reduce = useReducedMotion();\n  const [shown, setShown] = useState(value);\n  const from = useRef(value);\n  useEffect(() => {\n    if (reduce) {\n      setShown(value);\n      return;\n    }\n    const start = from.current;\n    const t0 = performance.now();\n    let raf = 0;\n    const tick = (t: number) => {\n      const p = Math.min(1, (t - t0) / 500);\n      const e = 1 - (1 - p) ** 3;\n      setShown(Math.round(start + (value - start) * e));\n      if (p < 1) raf = requestAnimationFrame(tick);\n      else from.current = value;\n    };\n    raf = requestAnimationFrame(tick);\n    return () => {\n      cancelAnimationFrame(raf);\n      from.current = value;\n    };\n  }, [value, reduce]);\n  return <span className=\"tabular-nums\">{shown}%</span>;\n}\n\nfunction ConfidenceMeter({ value }: { value: number }) {\n  const reduce = useReducedMotion();\n  const l = level(value);\n  const bars = 5;\n  const filled = Math.max(1, Math.round(value * bars));\n  return (\n    <div className=\"flex items-center gap-2.5\">\n      <div aria-hidden className=\"flex h-4 items-end gap-[3px]\">\n        {Array.from({ length: bars }, (_, i) => (\n          <span className=\"relative w-[5px] overflow-hidden rounded-full bg-muted\" key={i} style={{ height: 6 + i * 2.5 }}>\n            <motion.span\n              animate={{ scaleY: i < filled ? 1 : 0 }}\n              className={cn(\"absolute inset-0 origin-bottom rounded-full transition-colors duration-300\", l.bar)}\n              initial={false}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 22, delay: i * 0.04 }}\n            />\n          </span>\n        ))}\n      </div>\n      <span className={cn(\"font-medium text-xs transition-colors\", l.text)}>\n        {l.label} · <RollingPercent value={Math.round(value * 100)} />\n      </span>\n    </div>\n  );\n}\n\nexport function Nudge({ options, undoMs = 5000, onAccept, onUndo, className }: NudgeProps) {\n  const reduce = useReducedMotion();\n  const [index, setIndex] = useState(0);\n  const [dir, setDir] = useState(1);\n  const [phase, setPhase] = useState<\"open\" | \"accepted\" | \"settled\" | \"dismissed\">(\"open\");\n  const current = options[index];\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n\n  useLayoutEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (phase !== \"accepted\") return;\n    const t = setTimeout(() => setPhase(\"settled\"), undoMs);\n    return () => clearTimeout(t);\n  }, [phase, undoMs]);\n\n  const pick = (i: number) => {\n    if (i === index) return;\n    setDir(i > index ? 1 : -1);\n    setIndex(i);\n  };\n\n  return (\n    <section\n      aria-label=\"Suggestion\"\n      className={cn(\n        \"relative w-full max-w-[400px] overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xs/5\",\n        className,\n      )}\n    >\n      <motion.div\n        animate={{ height }}\n        initial={false}\n        transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 320, damping: 32 }}\n      >\n      <div ref={innerRef}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {phase === \"open\" ? (\n          <motion.div\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, filter: \"blur(4px)\" }}\n            initial={reduce ? false : { opacity: 0, scale: 0.97 }}\n            key=\"open\"\n            transition={spring}\n          >\n            <header className=\"flex items-center gap-2 px-4 pt-4 text-xs\">\n              <span className=\"flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n                <SparklesIcon className=\"size-3\" />\n              </span>\n              <span className=\"font-medium\">{index === 0 ? \"Suggested\" : \"Alternative\"}</span>\n              <span className=\"ms-auto\">\n                <ConfidenceMeter value={current.confidence} />\n              </span>\n            </header>\n\n            <motion.div className=\"relative overflow-hidden px-4 pt-3\" layout={!reduce} transition={spring}>\n              <AnimatePresence custom={dir} initial={false} mode=\"popLayout\">\n                <motion.div\n                  animate={{ x: 0, opacity: 1, filter: \"blur(0px)\" }}\n                  custom={dir}\n                  exit=\"exit\"\n                  initial={reduce ? false : { x: dir * 40, opacity: 0, filter: \"blur(4px)\" }}\n                  key={current.id}\n                  transition={spring}\n                  variants={{\n                    exit: (d: number) =>\n                      reduce ? { opacity: 0 } : { x: d * -40, opacity: 0, filter: \"blur(4px)\", transition: { duration: 0.18 } },\n                  }}\n                >\n                  <h2 className=\"text-balance font-medium text-base\">{current.title}</h2>\n                  {current.detail && <p className=\"mt-1 text-pretty text-muted-foreground text-sm\">{current.detail}</p>}\n                  {current.reasons && current.reasons.length > 0 && (\n                    <ul className=\"mt-3 space-y-1.5\">\n                      {current.reasons.map((r, i) => (\n                        <motion.li\n                          animate={{ opacity: 1, y: 0 }}\n                          className=\"flex gap-2 text-sm\"\n                          initial={reduce ? false : { opacity: 0, y: 4 }}\n                          key={r}\n                          transition={{ ...spring, delay: reduce ? 0 : 0.06 + i * 0.05 }}\n                        >\n                          <LightbulbIcon className=\"mt-0.5 size-3.5 shrink-0 text-muted-foreground\" />\n                          <span className=\"text-foreground/90\">{r}</span>\n                        </motion.li>\n                      ))}\n                    </ul>\n                  )}\n                </motion.div>\n              </AnimatePresence>\n            </motion.div>\n\n            {options.length > 1 && (\n              <motion.div className=\"px-4 pt-4\" layout={reduce ? false : \"position\"} transition={spring}>\n                <p className=\"mb-1.5 text-muted-foreground text-xs\">Options</p>\n                <div aria-label=\"Options\" className=\"flex flex-wrap gap-1.5\" role=\"radiogroup\">\n                  {options.map((o, i) => {\n                    const active = i === index;\n                    return (\n                      <button\n                        aria-checked={active}\n                        className={cn(\n                          \"relative flex h-7 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 text-xs outline-none transition-[color,background-color,border-color,scale] duration-150 ease-out after:absolute after:-inset-y-1 after:inset-x-0 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\",\n                          active\n                            ? \"border-foreground/20 bg-accent font-medium text-foreground\"\n                            : \"text-muted-foreground hover:bg-accent/50 hover:text-foreground\",\n                        )}\n                        key={o.id}\n                        onClick={() => pick(i)}\n                        role=\"radio\"\n                        type=\"button\"\n                      >\n                        <span className={cn(\"size-1.5 rounded-full\", level(o.confidence).bar)} />\n                        {o.label}\n                      </button>\n                    );\n                  })}\n                </div>\n              </motion.div>\n            )}\n\n            <motion.footer\n              className=\"flex items-center justify-end gap-2 p-4\"\n              layout={reduce ? false : \"position\"}\n              transition={spring}\n            >\n              <Button className={press} onClick={() => setPhase(\"dismissed\")} size=\"sm\" variant=\"ghost\">\n                Not now\n              </Button>\n              <Button\n                className={press}\n                onClick={() => {\n                  setPhase(\"accepted\");\n                  onAccept?.(current);\n                }}\n                size=\"sm\"\n              >\n                <CheckIcon />\n                Accept\n              </Button>\n            </motion.footer>\n          </motion.div>\n        ) : (\n          <motion.div\n            animate={{ opacity: 1, scale: 1 }}\n            className=\"flex items-center gap-3 p-4\"\n            initial={reduce ? false : { opacity: 0, scale: 0.97 }}\n            key=\"after\"\n            transition={spring}\n          >\n            <motion.span\n              animate={{ scale: 1, rotate: 0 }}\n              className={cn(\n                \"flex size-9 shrink-0 items-center justify-center rounded-full\",\n                phase === \"dismissed\" ? \"bg-muted text-muted-foreground\" : \"bg-success/12 text-success-foreground\",\n              )}\n              initial={reduce ? false : { scale: 0.7, rotate: -20 }}\n              transition={{ type: \"spring\", stiffness: 420, damping: 22 }}\n            >\n              {phase === \"dismissed\" ? <RotateCcwIcon className=\"size-4\" /> : <CheckIcon className=\"size-4\" />}\n            </motion.span>\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"truncate font-medium text-sm\">\n                {phase === \"dismissed\" ? \"Suggestion dismissed\" : current.result ?? \"Done\"}\n              </p>\n              <p className=\"truncate text-muted-foreground text-xs\">\n                {phase === \"dismissed\" ? \"We'll suggest something else later.\" : current.title}\n              </p>\n            </div>\n            {phase === \"accepted\" ? (\n              <Button\n                className={cn(\"relative overflow-hidden\", press)}\n                onClick={() => {\n                  setPhase(\"open\");\n                  onUndo?.(current);\n                }}\n                size=\"sm\"\n                variant=\"outline\"\n              >\n                <Undo2Icon />\n                Undo\n                <motion.span\n                  animate={{ scaleX: 0 }}\n                  aria-hidden\n                  className=\"absolute inset-x-0 bottom-0 h-0.5 origin-left bg-foreground/30\"\n                  initial={{ scaleX: 1 }}\n                  transition={{ duration: undoMs / 1000, ease: \"linear\" }}\n                />\n              </Button>\n            ) : (\n              <Button\n                aria-label=\"Show suggestion again\"\n                className={press}\n                onClick={() => {\n                  setIndex(0);\n                  setPhase(\"open\");\n                }}\n                size=\"icon-sm\"\n                variant=\"ghost\"\n              >\n                <RotateCcwIcon />\n              </Button>\n            )}\n          </motion.div>\n        )}\n      </AnimatePresence>\n      </div>\n      </motion.div>\n    </section>\n  );\n}\n"
    }
  ]
}