{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-circle",
  "title": "Progress Circle",
  "description": "Circular progress indicator drawn in SVG, with tone and size variants.",
  "dependencies": [
    "class-variance-authority@^0.7.1"
  ],
  "registryDependencies": [
    "@pestoui/arc",
    "@pestoui/motion",
    "@pestoui/number-animation",
    "@pestoui/style",
    "@pestoui/utils"
  ],
  "files": [
    {
      "path": "pestoui/ui/progress-circle.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { AnimatedNumber } from \"@/components/ui/number-animation\"\nimport { arcOffset, circumferenceOf, clampPercent } from \"@/lib/arc\"\nimport { DURATION_SPRING_GLIDE } from \"@/lib/motion\"\nimport { cn } from \"@/lib/utils\"\n\n/* One motion contract, shared by both arcs — and that is load-bearing rather\n   than tidy. In the gauge track the blank at each junction *is* the difference\n   between the two dash offsets, so if the arcs ran different curves or\n   durations the gap would breathe on the way to the value.\n\n   The offset travels through a custom property so `@starting-style` can supply\n   the mount value: the arc sweeps up from empty on first paint with no state,\n   no effect and no first-paint flash, and an engine without support simply\n   renders the finished arc rather than breaking.\n\n   Glide, and glide is tuned slightly overdamped for exactly this reason: an arc\n   that overshoots has, for a few frames, reported a number that was never true. */\nconst progressCircleArc =\n  \"transition-[stroke-dashoffset] duration-spring-glide ease-spring-glide \" +\n  \"[stroke-dashoffset:var(--arc-offset)] starting:[stroke-dashoffset:var(--arc-length)]\"\n\n/* Only the indicator stroke carries the tone — the track stays neutral in every\n   tone so the ring reads as one control, not six.\n\n   `selected` is the brand ring. --primary is a fill; at three to five pixels of\n   stroke it stops being legible, so the arc takes --selected, the brand green\n   that survives being drawn this thin. */\nconst progressCircleVariants = cva(progressCircleArc, {\n  variants: {\n    tone: {\n      info: \"stroke-info\",\n      success: \"stroke-success\",\n      warning: \"stroke-warning\",\n      destructive: \"stroke-destructive\",\n      selected: \"stroke-selected\",\n      neutral: \"stroke-muted-foreground\",\n    },\n  },\n  defaultVariants: {\n    tone: \"info\",\n  },\n})\n\n/* Named sizes pin their own stroke and readout so the ring stays optically\n   even; a numeric size derives both from the box (same ratios as below). */\nconst progressCircleSizes = {\n  sm: { box: 36, stroke: 3, font: 11 },\n  default: { box: 48, stroke: 4, font: 13 },\n  lg: { box: 64, stroke: 5, font: 15 },\n} as const\n\ntype ProgressCircleSize = keyof typeof progressCircleSizes | number\n\ntype ProgressCircleTone = NonNullable<\n  VariantProps<typeof progressCircleVariants>[\"tone\"]\n>\n\ntype ProgressCircleTrack = \"ring\" | \"gauge\"\n\ninterface ProgressCircleProps\n  extends React.ComponentProps<\"div\">,\n    VariantProps<typeof progressCircleVariants> {\n  /** Current reading, clamped into `[min, max]`. */\n  value?: number\n  /** Range bounds. The defaults keep a plain 0–100 percentage unchanged. */\n  min?: number\n  max?: number\n\n  size?: ProgressCircleSize\n  /** Overrides the stroke the size would otherwise derive. */\n  strokeWidth?: number\n\n  /**\n   * `\"ring\"` — a continuous track sitting behind the indicator, the default.\n   * `\"gauge\"` — the remainder drawn as its own arc, held off the indicator by a\n   * blank gap at each junction. Reads crisper at larger diameters.\n   */\n  track?: ProgressCircleTrack\n  /** Gap per junction, as a percent of the circumference. Gauge track only. */\n  gapPercent?: number\n\n  /** Renders the reading dead centre. Ignored when `children` are passed. */\n  showValue?: boolean\n  /** Counts the reading up to its value instead of snapping to it. */\n  animateValue?: boolean\n  /** Formats the reading. Receives the clamped value *and* its percentage. */\n  formatValue?: (value: number, percent: number) => string\n\n  /** Centre slot — a caption, an icon, a stacked total. Wins over `showValue`. */\n  children?: React.ReactNode\n}\n\nfunction ProgressCircle({\n  className,\n  style,\n  value = 0,\n  min = 0,\n  max = 100,\n  size = \"default\",\n  strokeWidth,\n  track = \"ring\",\n  gapPercent = 5,\n  showValue = false,\n  animateValue = false,\n  formatValue,\n  tone = \"info\",\n  children,\n  ...props\n}: ProgressCircleProps) {\n  const geometry =\n    typeof size === \"number\"\n      ? {\n          box: size,\n          stroke: Math.max(2, Math.round(size * 0.085)),\n          font: Math.max(10, Math.round(size * 0.27)),\n        }\n      : progressCircleSizes[size]\n\n  const box = geometry.box\n  const stroke = strokeWidth ?? geometry.stroke\n\n  /* An inverted or zero-width range divides by zero. Read it as empty — SVG\n     paints NaN as a missing arc and assistive tech announces nothing at all,\n     so the silent failure is worse than the honest one. */\n  const span = max - min\n  const usableRange = Number.isFinite(span) && span > 0\n  const raw = Number.isFinite(value) ? value : min\n  const clampedValue = usableRange ? Math.min(max, Math.max(min, raw)) : min\n  const toPercent = (n: number) =>\n    usableRange ? clampPercent(((n - min) / span) * 100) : 0\n  const percent = toPercent(clampedValue)\n  const rounded = Math.round(percent)\n\n  const center = box / 2\n  const radius = Math.max((box - stroke) / 2, 0)\n  const circumference = circumferenceOf(radius)\n  const indicatorLength = circumference * (percent / 100)\n\n  /* Gauge geometry.\n\n     The blank sits at both junctions: after the indicator's head, and again\n     before its tail at twelve o'clock. A round cap overhangs its arc by half a\n     stroke at each end, so a pair of facing caps eats a whole stroke width out\n     of the gap — floor the gap above that or the two arcs touch at low values\n     on the small sizes.\n\n     Boundaries fall out of the arithmetic rather than needing a special case.\n     At zero there is no indicator, so the two gaps merge into one blank of\n     double width at twelve o'clock — which is what the ring looks like a frame\n     earlier anyway, so nothing jumps. Once the remainder is shorter than the\n     two gaps the track goes to zero and stops painting, so a full ring leaves\n     no orphan notch behind. */\n  const gapFraction = Math.min(0.25, Math.max(0, gapPercent / 100))\n  const gapLength =\n    gapFraction > 0 ? Math.max(gapFraction * circumference, stroke * 1.5) : 0\n  const gapDegrees = circumference > 0 ? (gapLength / circumference) * 360 : 0\n  const trackLength = Math.max(0, circumference - indicatorLength - gapLength * 2)\n  const trackPercent = circumference > 0 ? (trackLength / circumference) * 100 : 0\n\n  const arcStyle = (offset: number) =>\n    ({\n      \"--arc-length\": `${circumference}`,\n      \"--arc-offset\": `${offset}`,\n    }) as React.CSSProperties\n\n  /* Mirror the remainder so it grows backwards from twelve o'clock — its far\n     end stays pinned there while its near end tracks the indicator — then\n     rotate it by exactly one gap, which is where the pinned end's blank comes\n     from. Both steps depend on the gap alone and never on the value, so this\n     string is constant for the life of the component and the only thing that\n     moves is still one scalar per arc. */\n  const gaugeTransform =\n    `rotate(${-gapDegrees} ${center} ${center})` +\n    ` translate(0 ${box}) scale(1 -1)`\n\n  const readoutClass =\n    \"leading-none font-semibold tracking-title tabular-nums text-foreground\"\n  const readoutText = formatValue\n    ? formatValue(clampedValue, percent)\n    : `${rounded}`\n\n  /* Counting the raw value rather than the percentage keeps one formatter for\n     both paths: the percentage is derived per frame from the value in flight,\n     so `formatValue` sees the same pair mid-count that it sees at rest. */\n  const readout = animateValue ? (\n    <AnimatedNumber\n      data-slot=\"progress-circle-value\"\n      value={clampedValue}\n      duration={DURATION_SPRING_GLIDE}\n      format={(n) =>\n        formatValue\n          ? formatValue(n, toPercent(n))\n          : `${Math.round(toPercent(n))}`\n      }\n      className={readoutClass}\n      style={{ fontSize: geometry.font }}\n    />\n  ) : (\n    <span\n      data-slot=\"progress-circle-value\"\n      className={readoutClass}\n      style={{ fontSize: geometry.font }}\n    >\n      {readoutText}\n    </span>\n  )\n\n  const centre = children ?? (showValue ? readout : null)\n\n  return (\n    <div\n      data-slot=\"progress-circle\"\n      data-tone={tone}\n      data-track={track}\n      role=\"progressbar\"\n      /* The raw reading, not the percentage — with a range the two diverge, and\n         a progressbar's value is expected to sit inside its own bounds. */\n      aria-valuenow={clampedValue}\n      aria-valuemin={min}\n      aria-valuemax={max}\n      aria-valuetext={formatValue ? readoutText : `${rounded}%`}\n      className={cn(\n        \"relative inline-flex shrink-0 items-center justify-center\",\n        className\n      )}\n      style={{ width: box, height: box, ...style }}\n      {...props}\n    >\n      <svg\n        width={box}\n        height={box}\n        viewBox={`0 0 ${box} ${box}`}\n        aria-hidden=\"true\"\n        /* Start the sweep at 12 o'clock; the readout sits outside the SVG so\n           it never inherits this rotation. */\n        className=\"-rotate-90\"\n      >\n        {track === \"gauge\" ? (\n          /* The transform rides on a group, not on the shape: `transform-origin`\n             on an SVG shape needs `transform-box` to mean the same thing in\n             every engine, and a group sidesteps that question entirely. */\n          <g transform={gaugeTransform}>\n            <circle\n              data-slot=\"progress-circle-track\"\n              cx={center}\n              cy={center}\n              r={radius}\n              fill=\"none\"\n              strokeWidth={stroke}\n              strokeLinecap={trackLength > 0 ? \"round\" : \"butt\"}\n              strokeDasharray={circumference}\n              className={cn(progressCircleArc, \"stroke-border dark:stroke-input\")}\n              style={arcStyle(arcOffset(circumference, trackPercent))}\n            />\n          </g>\n        ) : (\n          <circle\n            data-slot=\"progress-circle-track\"\n            cx={center}\n            cy={center}\n            r={radius}\n            fill=\"none\"\n            strokeWidth={stroke}\n            className=\"stroke-border dark:stroke-input\"\n          />\n        )}\n        <circle\n          data-slot=\"progress-circle-indicator\"\n          cx={center}\n          cy={center}\n          r={radius}\n          fill=\"none\"\n          strokeWidth={stroke}\n          /* A round cap at 0% would still paint a dot on an empty ring. */\n          strokeLinecap={percent > 0 ? \"round\" : \"butt\"}\n          strokeDasharray={circumference}\n          className={cn(progressCircleVariants({ tone }))}\n          style={arcStyle(arcOffset(circumference, percent))}\n        />\n      </svg>\n      {centre ? (\n        <div\n          data-slot=\"progress-circle-center\"\n          /* `aria-valuetext` on the root already announces the reading. Hiding\n             the whole slot is what keeps a counter dropped in here from opening\n             a second live region for the same quantity — two announcements\n             racing each other is worse than one that is merely terse. */\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 flex flex-col items-center justify-center gap-0.5 text-center\"\n        >\n          {centre}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport { ProgressCircle, progressCircleVariants }\nexport type {\n  ProgressCircleProps,\n  ProgressCircleSize,\n  ProgressCircleTone,\n  ProgressCircleTrack,\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}