{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "credit-gauge",
  "type": "registry:component",
  "title": "Credit Gauge",
  "description": "See where this month's credits went: a split bar by category with a warning line and a hard limit, daily spend against budget, and a plan button that gets more insistent as you close in on the cap.",
  "categories": [
    "component",
    "Status"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/badge.json",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/credit-gauge.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowUpRightIcon, SparklesIcon } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useRef, useState } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CreditCategory {\n  id: string;\n  label: string;\n  used: number;\n}\n\nexport interface CreditGaugeProps {\n  title?: string;\n  plan?: string;\n  categories: CreditCategory[];\n  limit: number;\n  warnAt?: number;\n  daily: number[];\n  daysInPeriod?: number;\n  periodStart?: Date;\n  unit?: string;\n  onUpgrade?: () => void;\n  className?: string;\n}\n\nconst COLORS = [\"var(--color-chart-1)\", \"var(--color-chart-2)\", \"var(--color-chart-3)\", \"var(--color-chart-4)\", \"var(--color-chart-5)\"];\nconst int = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 0 });\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\nfunction Face({ n, turn }: { n: number; turn: MotionValue<number> }) {\n  const y = useTransform(turn, (t) => {\n    let offset = (((n - t) % 10) + 10) % 10;\n    if (offset > 5) offset -= 10;\n    return `${offset * 100}%`;\n  });\n  return (\n    <motion.span className=\"absolute inset-x-0 top-0 block h-[1.15em] text-center leading-[1.15em]\" style={{ y }}>\n      {n}\n    </motion.span>\n  );\n}\n\nfunction Wheel({ digit, instant, delay }: { digit: number; instant: boolean; delay: number }) {\n  const reduce = useReducedMotion();\n  const turn = useMotionValue(digit);\n  const target = useRef(digit);\n  useEffect(() => {\n    let step = digit - (((target.current % 10) + 10) % 10);\n    if (step > 5) step -= 10;\n    else if (step < -5) step += 10;\n    target.current += step;\n    if (reduce || instant) turn.jump(target.current);\n    else if (step !== 0 || (!turn.isAnimating() && turn.get() !== target.current))\n      animate(turn, target.current, { type: \"spring\", stiffness: 280, damping: 34, delay });\n  }, [digit, instant, delay, reduce, turn]);\n  useEffect(() => () => turn.stop(), [turn]);\n  return (\n    <span className=\"relative inline-block h-[1.15em] overflow-hidden [mask-image:linear-gradient(transparent,black_20%,black_80%,transparent)]\">\n      <span className=\"invisible block leading-[1.15em]\">0</span>\n      {DIGITS.map((n) => (\n        <Face key={n} n={n} turn={turn} />\n      ))}\n    </span>\n  );\n}\n\nfunction Roll({\n  text,\n  className,\n  instant = false,\n  delay = 0,\n}: {\n  text: string;\n  className?: string;\n  instant?: boolean;\n  delay?: number;\n}) {\n  let placeFromRight = 0;\n  let other = 0;\n  const glyphs = [...text]\n    .reverse()\n    .map((char) => {\n      const isDigit = char >= \"0\" && char <= \"9\";\n      return { char, isDigit, key: isDigit ? `d${placeFromRight++}` : `s${other++}${char}` };\n    })\n    .reverse();\n  return (\n    <span className={cn(\"inline-flex whitespace-nowrap tabular-nums leading-none\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex\">\n        {glyphs.map((g) =>\n          g.isDigit ? (\n            <Wheel delay={delay} digit={Number(g.char)} instant={instant} key={g.key} />\n          ) : (\n            <span className=\"inline-block h-[1.15em] leading-[1.15em]\" key={g.key}>\n              {g.char === \" \" ? \" \" : g.char}\n            </span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n\ntype Level = \"calm\" | \"busy\" | \"near\" | \"over\";\n\nfunction levelFor(ratio: number, warnAt: number): Level {\n  if (ratio >= 1) return \"over\";\n  if (ratio >= warnAt) return \"near\";\n  if (ratio >= 0.6) return \"busy\";\n  return \"calm\";\n}\n\nconst LABELS: Record<Level, string> = {\n  calm: \"Manage plan\",\n  busy: \"Add credits\",\n  near: \"Upgrade plan\",\n  over: \"Upgrade now\",\n};\n\nfunction PlanButton({ level, onClick }: { level: Level; onClick?: () => void }) {\n  const reduce = useReducedMotion();\n  const label = LABELS[level];\n  const variant = level === \"over\" ? \"destructive\" : level === \"near\" ? \"default\" : \"outline\";\n\n  return (\n    <Button\n      className=\"min-w-[8.25rem] overflow-hidden transition-[box-shadow,transform,background-color,border-color,color] duration-150 ease-out motion-safe:active:scale-[0.97]\"\n      onClick={onClick}\n      size=\"sm\"\n      variant={variant}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ opacity: 1, y: 0 }}\n          className=\"inline-flex items-center gap-1.5\"\n          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10 }}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}\n          key={label}\n          transition={{ type: \"spring\", duration: 0.3, bounce: 0 }}\n        >\n          {level === \"near\" || level === \"over\" ? <SparklesIcon /> : null}\n          {label}\n          {level === \"calm\" ? <ArrowUpRightIcon /> : null}\n        </motion.span>\n      </AnimatePresence>\n    </Button>\n  );\n}\n\nexport function CreditGauge({\n  title = \"AI credits\",\n  plan = \"Pro\",\n  categories,\n  limit,\n  warnAt = 0.8,\n  daily,\n  daysInPeriod = 30,\n  periodStart,\n  unit = \"credits\",\n  onUpgrade,\n  className,\n}: CreditGaugeProps) {\n  const reduce = useReducedMotion();\n  const [hoverDay, setHoverDay] = useState<number | null>(null);\n  const [hoverCat, setHoverCat] = useState<string | null>(null);\n  const mounted = useRef(false);\n  useEffect(() => {\n    mounted.current = true;\n  }, []);\n\n  const total = categories.reduce((sum, c) => sum + c.used, 0);\n  const ratio = total / limit;\n  const level = levelFor(ratio, warnAt);\n  const scale = Math.max(limit, total) * 1.04;\n  const limitPos = (limit / scale) * 100;\n  const warnPos = ((limit * warnAt) / scale) * 100;\n\n  const daysLeft = Math.max(0, daysInPeriod - daily.length);\n  const recent = daily.slice(-7);\n  const pace = recent.length ? recent.reduce((a, b) => a + b, 0) / recent.length : 0;\n  const daysToLimit = pace > 0 ? Math.ceil((limit - total) / pace) : Number.POSITIVE_INFINITY;\n  const status =\n    level === \"over\"\n      ? `${int.format(total - limit)} ${unit} over your limit`\n      : daysToLimit <= daysLeft\n        ? `Hits the limit in ~${daysToLimit} day${daysToLimit === 1 ? \"\" : \"s\"}`\n        : `On pace · resets in ${daysLeft} day${daysLeft === 1 ? \"\" : \"s\"}`;\n\n  const spring = reduce ? { duration: 0 } : ({ type: \"spring\", duration: 0.5, bounce: 0 } as const);\n\n  let acc = 0;\n  const segments = categories.map((c, i) => {\n    const start = (acc / scale) * 100;\n    acc += c.used;\n    const end = (acc / scale) * 100;\n    return { ...c, start, end, color: COLORS[i % COLORS.length] };\n  });\n\n  const W = 340;\n  const H = 52;\n  const gap = 2;\n  const barW = (W - gap * (daysInPeriod - 1)) / daysInPeriod;\n  const budget = limit / daysInPeriod;\n  const peak = Math.max(budget * 1.6, ...daily);\n  const yFor = (v: number) => H - (v / peak) * (H - 4);\n  const dayLabel = (i: number) => {\n    if (!periodStart) return `Day ${i + 1}`;\n    const d = new Date(periodStart);\n    d.setDate(d.getDate() + i);\n    return d.toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" });\n  };\n  const lastDay = daily.length - 1;\n  const focusDay = hoverDay !== null && hoverDay <= lastDay ? hoverDay : lastDay;\n\n  function onDaysKey(e: KeyboardEvent<HTMLDivElement>) {\n    if (e.key === \"ArrowLeft\") setHoverDay(Math.max(0, focusDay - 1));\n    else if (e.key === \"ArrowRight\") setHoverDay(Math.min(lastDay, focusDay + 1));\n    else if (e.key === \"Home\") setHoverDay(0);\n    else if (e.key === \"End\" || e.key === \"Escape\") setHoverDay(null);\n    else return;\n    e.preventDefault();\n  }\n\n  return (\n    <section\n      aria-label={`${title} usage`}\n      className={cn(\"flex w-full max-w-sm flex-col gap-5 rounded-2xl border bg-card p-5 text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex items-start justify-between gap-3\">\n        <div className=\"flex flex-col gap-1.5\">\n          <span className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n            <SparklesIcon className=\"size-3.5\" />\n            {title}\n          </span>\n          <div className=\"flex items-baseline gap-1.5\">\n            <Roll className=\"font-semibold text-3xl tracking-tight\" text={int.format(total)} />\n            <span className=\"text-muted-foreground text-sm tabular-nums\">/ {int.format(limit)}</span>\n          </div>\n        </div>\n        <div className=\"flex flex-col items-end gap-1.5\">\n          <Badge variant=\"outline\">{plan}</Badge>\n          <span\n            className={cn(\n              \"font-medium text-sm tabular-nums transition-colors duration-300\",\n              level === \"over\" ? \"text-destructive-foreground\" : level === \"near\" ? \"text-warning-foreground\" : \"text-muted-foreground\",\n            )}\n          >\n            <Roll text={`${Math.round(ratio * 100)}%`} />\n          </span>\n        </div>\n      </header>\n\n      <div className=\"flex flex-col gap-2\">\n        <div\n          aria-label={`${int.format(total)} of ${int.format(limit)} ${unit} used`}\n          aria-valuemax={limit}\n          aria-valuemin={0}\n          aria-valuenow={Math.min(total, limit)}\n          className=\"relative h-3 overflow-x-clip\"\n          role=\"meter\"\n        >\n          <div className=\"absolute inset-0 overflow-hidden rounded-full bg-muted\">\n            {segments.map((s, i) => (\n              <motion.div\n                animate={{\n                  clipPath: `inset(0px calc(${(100 - s.end).toFixed(3)}% + 0px) 0px calc(${s.start.toFixed(3)}% + ${i ? 2 : 0}px))`,\n                  opacity: hoverCat && hoverCat !== s.id ? 0.3 : 1,\n                }}\n                className=\"absolute inset-0\"\n                initial={false}\n                key={s.id}\n                style={{ backgroundColor: s.color }}\n                transition={spring}\n              />\n            ))}\n            <motion.div\n              animate={{ clipPath: `inset(0px 0px 0px ${limitPos.toFixed(3)}%)`, opacity: level === \"over\" ? 1 : 0 }}\n              aria-hidden\n              className=\"absolute inset-0\"\n              initial={false}\n              style={{\n                backgroundImage:\n                  \"repeating-linear-gradient(135deg, color-mix(in srgb, var(--color-destructive) 70%, transparent) 0 3px, transparent 3px 6px)\",\n              }}\n              transition={spring}\n            />\n          </div>\n          <motion.div animate={{ x: `${warnPos}%` }} aria-hidden className=\"pointer-events-none absolute inset-0\" initial={false} transition={spring}>\n            <span className=\"absolute -inset-y-1 left-0 w-px bg-warning\" />\n          </motion.div>\n          <motion.div animate={{ x: `${limitPos}%` }} aria-hidden className=\"pointer-events-none absolute inset-0\" initial={false} transition={spring}>\n            <span className=\"absolute -inset-y-1.5 left-0 w-0.5 -translate-x-1/2 rounded-full bg-foreground\" />\n          </motion.div>\n        </div>\n        <div aria-hidden className=\"relative h-4 overflow-x-clip text-[10px] text-muted-foreground tabular-nums\">\n          <motion.div animate={{ x: `${warnPos}%` }} className=\"absolute inset-0\" initial={false} transition={spring}>\n            <span className=\"absolute left-0 -translate-x-1/2\">{Math.round(warnAt * 100)}%</span>\n          </motion.div>\n          <motion.div animate={{ x: `${limitPos}%` }} className=\"absolute inset-0\" initial={false} transition={spring}>\n            <span className=\"absolute left-0 -translate-x-1/2 font-medium text-foreground\">Limit</span>\n          </motion.div>\n        </div>\n\n        <ul className=\"-mx-1 grid grid-cols-2 gap-x-3\">\n          {segments.map((s) => (\n            <li key={s.id}>\n              <button\n                className=\"flex h-7 w-full cursor-default items-center gap-2 rounded-md px-1 text-left text-xs outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring\"\n                onBlur={() => setHoverCat(null)}\n                onFocus={() => setHoverCat(s.id)}\n                onPointerEnter={() => setHoverCat(s.id)}\n                onPointerLeave={() => setHoverCat(null)}\n                type=\"button\"\n              >\n                <span className=\"size-2 shrink-0 rounded-full\" style={{ backgroundColor: s.color }} />\n                <span className=\"truncate text-muted-foreground\">{s.label}</span>\n                <span className=\"ms-auto font-medium text-foreground tabular-nums\">{int.format(s.used)}</span>\n              </button>\n            </li>\n          ))}\n        </ul>\n      </div>\n\n      <div\n        aria-label={`Daily ${unit}. Use the arrow keys to step through days.`}\n        className=\"flex flex-col gap-2 rounded-xl bg-muted/60 p-3 outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        onBlur={() => setHoverDay(null)}\n        onKeyDown={onDaysKey}\n        role=\"group\"\n        tabIndex={0}\n      >\n        <div className=\"flex items-baseline justify-between text-xs\">\n          <span className=\"text-muted-foreground\">{dayLabel(focusDay)}</span>\n          <span className=\"font-medium tabular-nums\">\n            {int.format(daily[focusDay] ?? 0)} <span className=\"font-normal text-muted-foreground\">{unit}</span>\n          </span>\n        </div>\n        <svg\n          aria-hidden\n          className=\"h-13 w-full overflow-visible\"\n          onPointerLeave={() => setHoverDay(null)}\n          preserveAspectRatio=\"none\"\n          viewBox={`0 0 ${W} ${H}`}\n        >\n          {Array.from({ length: daysInPeriod }, (_, i) => {\n            const v = daily[i];\n            const x = i * (barW + gap);\n            if (v === undefined) {\n              return <rect className=\"fill-muted-foreground/15\" height={2} key={i} rx={1} width={barW} x={x} y={H - 2} />;\n            }\n            const over = v > budget * 1.25;\n            const active = i === focusDay;\n            return (\n              <g key={i} onPointerEnter={() => setHoverDay(i)}>\n                <rect fill=\"transparent\" height={H} width={barW + gap} x={x - gap / 2} y={0} />\n                <motion.rect\n                  animate={{ y: yFor(v), height: H - yFor(v) }}\n                  initial={mounted.current ? { y: H, height: 0 } : false}\n                  rx={1.5}\n                  style={{\n                    fill: over ? \"var(--color-warning)\" : active ? \"var(--color-foreground)\" : \"var(--color-muted-foreground)\",\n                    opacity: active || over ? 1 : 0.35,\n                  }}\n                  transition={spring}\n                  width={barW}\n                  x={x}\n                />\n              </g>\n            );\n          })}\n          <line\n            className=\"stroke-foreground/40\"\n            strokeDasharray=\"3 3\"\n            strokeWidth={1}\n            vectorEffect=\"non-scaling-stroke\"\n            x1={0}\n            x2={W}\n            y1={yFor(budget)}\n            y2={yFor(budget)}\n          />\n        </svg>\n        <div className=\"flex justify-between text-[10px] text-muted-foreground\">\n          <span>{dayLabel(0)}</span>\n          <span className=\"flex items-center gap-1\">\n            <span className=\"inline-block w-3 border-foreground/40 border-t border-dashed\" />\n            daily budget {int.format(budget)}\n          </span>\n        </div>\n      </div>\n\n      <footer className=\"flex items-center justify-between gap-3\">\n        <p\n          aria-live=\"polite\"\n          className={cn(\n            \"text-pretty text-xs transition-colors duration-300\",\n            level === \"over\" ? \"text-destructive-foreground\" : level === \"near\" ? \"text-warning-foreground\" : \"text-muted-foreground\",\n          )}\n        >\n          {status}\n        </p>\n        <PlanButton level={level} onClick={onUpgrade} />\n      </footer>\n    </section>\n  );\n}\n"
    }
  ]
}