{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "usage-meter",
  "title": "Usage Meter",
  "description": "Count against a quota, drawn as a row of discrete pips.",
  "dependencies": [
    "class-variance-authority@^0.7.1"
  ],
  "registryDependencies": [
    "@pestoui/style",
    "@pestoui/utils"
  ],
  "files": [
    {
      "path": "pestoui/components/usage-meter.tsx",
      "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* A count against a quota — 9 of 40 emails, 3 of 5 seats — drawn as a row of\n   discrete pips.\n *\n * Deliberately not a mode of `Progress`. That component reports one continuous\n * value and hands Radix the `progressbar` role, which describes a task\n * advancing toward completion. This describes a level already spent out of an\n * allowance, which is what `role=\"meter\"` is for. The reading differs too: a\n * bar says \"56% of the way there\", a pip row says \"14 gone, 11 left\" — and the\n * discreteness is the entire reason to draw it, because a countable row is the\n * only shape that answers \"how many do I have left\" without arithmetic.\n *\n * Nor a `TickStrip`, which is a series over time. Nothing here is ordered by\n * when it happened and no pip means more than any other; they are\n * interchangeable tallies.\n *\n * The tone lands on the root as a text colour and every pip paints from\n * currentColor. That is what makes over-quota a one-class state rather than a\n * second set of eleven fills: the row goes destructive by swapping the colour\n * on the root, and the pips follow without knowing why. */\nconst usageMeterVariants = cva(\"flex w-full min-w-0 flex-col\", {\n  variants: {\n    tone: {\n      selected: \"text-selected\",\n      success: \"text-success\",\n      warning: \"text-warning\",\n      destructive: \"text-destructive\",\n      info: \"text-info\",\n      muted: \"text-muted-foreground\",\n      \"chart-1\": \"text-chart-1\",\n      \"chart-2\": \"text-chart-2\",\n      \"chart-3\": \"text-chart-3\",\n      \"chart-4\": \"text-chart-4\",\n      \"chart-5\": \"text-chart-5\",\n    },\n    /* Both measurements ride on the root as custom properties rather than\n       becoming a second cva. The gutter belongs to the track and the height\n       belongs to the pips — two different elements — and a size axis declared\n       twice is a size axis that can disagree with itself. */\n    size: {\n      sm: \"gap-1.5 [--usage-pip:--spacing(2)] [--usage-gutter:--spacing(0.5)]\",\n      default: \"gap-2 [--usage-pip:--spacing(3)] [--usage-gutter:--spacing(1)]\",\n    },\n    /* The base string above is what `pips` has always rendered, and it stays\n       untouched: each variant only adds classes that twMerge resolves against\n       it, so the default path emits the same class list it did before this\n       axis existed.\n     *\n     * `ticks` lays the row out inline — a strip of fixed-width ticks with the\n     * readout after it, in reading order — and wraps the readout under the\n     * strip rather than overflowing a narrow column. `bar` keeps the column\n     * but draws one continuous line with the percentage sitting in a break in\n     * it; see the track below. */\n    variant: {\n      pips: \"\",\n      ticks: \"w-auto max-w-full flex-row flex-wrap items-center\",\n      bar: \"\",\n    },\n  },\n  defaultVariants: {\n    tone: \"selected\",\n    size: \"default\",\n    variant: \"pips\",\n  },\n})\n\n/* An unfilled pip takes the same `--muted` fill the rest of the family uses for\n   an empty track, so a spent pip is the only thing carrying colour.\n\n   `rounded-full` is not a style choice: the surface scale starts at 9.6px\n   (`--radius-sm`), which would swallow a pip whole and round it into a circle.\n   At these widths a full radius lands as a softened cap. */\nconst usageMeterPipVariants = cva(\n  \"h-(--usage-pip) min-w-0 flex-1 rounded-full transition-colors duration-base ease-out-expo\",\n  {\n    variants: {\n      filled: {\n        true: \"bg-current\",\n        false: \"bg-muted\",\n      },\n      /* `tick` overrides the stretch and the height rather than restating the\n         pill, for the same reason the root's variant axis does: the base\n         string is the pill, untouched. A tick is a fixed 2px column a little\n         taller than a pip of the same size, so a strip of them reads as a\n         tally rather than a squashed bar. */\n      shape: {\n        pill: \"\",\n        tick: \"h-[calc(var(--usage-pip)+--spacing(1))] w-0.5 flex-none\",\n      },\n    },\n    /* A row of thin ticks has almost no area, and the `--muted` fill that\n       carries a wide pip disappears at 2px — entirely so on a muted footer,\n       which is where a seat count usually sits. An unlit tick takes a\n       stronger neutral so the allowance left stays countable. */\n    compoundVariants: [\n      { filled: false, shape: \"tick\", class: \"bg-muted-foreground/25\" },\n    ],\n    defaultVariants: {\n      filled: false,\n      shape: \"pill\",\n    },\n  },\n)\n\n/* The cap on how many pips get drawn, and the point where a pip stops being a\n   unit and becomes a bucket.\n *\n * Under the cap each pip is exactly one unit, so 14 of 25 is literally 14 lit\n * pips — the property the whole component exists for. 30 is chosen to keep that\n * true for the quotas people actually count (seats, projects, integrations,\n * campaigns) rather than to be a round number; above it each pip stands for\n * `max / 30` units and the row goes back to reading as a proportion, with the\n * label carrying the exact figures.\n *\n * The ceiling is geometric, not arbitrary: at the default 4px gutter a 30-pip\n * row still leaves each pip about 4px in a 240px column. Past that the gutters\n * start eating the pips, and a quota of 1000 would be 1000 DOM nodes drawn\n * sub-pixel. */\nconst PIP_CAP = 30\n\n/* Counts are whole things. A fractional quota (2.5 GB of storage) keeps one\n   decimal rather than being rounded into a number the caller didn't pass. */\nfunction formatCount(value: number) {\n  return Number.isInteger(value) ? String(value) : value.toFixed(1)\n}\n\ntype UsageMeterTone = NonNullable<VariantProps<typeof usageMeterVariants>[\"tone\"]>\n\ntype UsageMeterSize = NonNullable<VariantProps<typeof usageMeterVariants>[\"size\"]>\n\ntype UsageMeterVariant = NonNullable<VariantProps<typeof usageMeterVariants>[\"variant\"]>\n\ntype UsageMeterProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The count spent. Negative and non-finite read as 0. */\n  value: number\n  /** The quota. Zero, negative and non-finite are treated as no quota at all. */\n  max: number\n  /**\n   * A suffix that names the figure, never a replacement for it. `pips` and\n   * `ticks` append it to the count — \"emails\" renders \"9/40 emails\". `bar`\n   * appends it to the percentage and defaults it to \"used\" — \"40% used\".\n   */\n  label?: React.ReactNode\n  /** Names the meter to a screen reader, e.g. \"Monthly email quota\". */\n  srLabel?: string\n  tone?: UsageMeterTone\n  size?: UsageMeterSize\n  /**\n   * `pips` (default): a full-width row of pips with the readout above it.\n   * `ticks`: fixed-width ticks with the readout inline after them.\n   * `bar`: one continuous line broken by an inline \"40% used\" caption; the\n   * caller shows the figures, so no readout row is drawn.\n   */\n  variant?: UsageMeterVariant\n}\n\nfunction UsageMeter({\n  value,\n  max,\n  label,\n  srLabel,\n  tone = \"selected\",\n  size = \"default\",\n  variant = \"pips\",\n  className,\n  \"aria-label\": ariaLabel,\n  ...props\n}: UsageMeterProps) {\n  const spent = Number.isFinite(value) ? Math.max(0, value) : 0\n\n  /* The `max <= 0` guard, resolved once into a nullable quota so nothing\n     downstream divides by it. Null is not an error state — an unset allowance\n     is an ordinary thing for a plan to have — so the row still renders, just\n     with nothing to measure against. */\n  const quota = Number.isFinite(max) && max > 0 ? max : null\n\n  /* Over-quota is a real reading, not a fault: the pips fill, the row turns\n     destructive whatever tone was asked for, and the percentage appears\n     because past 100% the count alone stops carrying the news. */\n  const over = quota != null && spent > quota\n  const percent = quota != null ? Math.round((spent / quota) * 100) : null\n  const paintedTone = over ? \"destructive\" : tone\n\n  const pips =\n    quota == null ? PIP_CAP : Math.min(PIP_CAP, Math.max(1, Math.ceil(quota)))\n\n  /* Two rules that only conflict at the very edges of the scale, in the order\n     they have to win.\n     1. A quota not yet spent must never draw as full, so an under-quota value\n        is held one pip short even when it rounds up to the whole row.\n     2. Anything spent at all lights a pip — a single call against a quota of\n        1000 rounds to nothing otherwise, and \"nothing used\" is a different\n        fact from \"barely used\".\n     Rule 1 outranks rule 2, which is what the nesting says: at `max={1}` a\n     half-spent quota leaves the only pip dark rather than claiming the\n     allowance is gone. */\n  const lit =\n    quota == null || spent <= 0\n      ? 0\n      : spent >= quota\n        ? pips\n        : Math.min(pips - 1, Math.max(1, Math.round((spent / quota) * pips)))\n\n  const readout =\n    quota == null\n      ? formatCount(spent)\n      : `${formatCount(spent)}/${formatCount(quota)}`\n\n  const reading =\n    quota == null\n      ? `${formatCount(spent)}, no quota set`\n      : `${formatCount(spent)} of ${formatCount(quota)}, ${percent}%`\n\n  /* `label` is a ReactNode and cannot be spoken, which is the whole reason\n     `srLabel` exists. This is the name only — the value belongs in\n     `aria-valuetext`, and duplicating it into the name would have a screen\n     reader announce the numbers twice. */\n  const name = ariaLabel ?? srLabel ?? \"Usage\"\n\n  /* A meter with no maximum has nothing to measure against, and ARIA defaults a\n     missing `aria-valuemax` to 100 — so a quotaless row left as a meter would\n     announce \"14%\" for a number that is not a percentage of anything. It\n     degrades to an image whose label states the count and says the quota is\n     unset. Typed as div props so `role` stays an `AriaRole` rather than\n     widening to `string`. */\n  const trackAria: React.ComponentProps<\"div\"> =\n    quota == null\n      ? { role: \"img\", \"aria-label\": `${name}: ${reading}` }\n      : {\n          role: \"meter\",\n          \"aria-label\": name,\n          \"aria-valuenow\": spent,\n          \"aria-valuemin\": 0,\n          \"aria-valuemax\": quota,\n          \"aria-valuetext\": reading,\n        }\n\n  /* The two new layouts branch out before the original one, which is left\n     exactly as it was — the pips path is the one every existing caller renders,\n     and keeping it as its own unshared return is what guarantees it cannot\n     drift while the others change. */\n  if (variant === \"ticks\") {\n    return (\n      <div\n        data-slot=\"usage-meter\"\n        data-tone={paintedTone}\n        data-size={size}\n        data-variant={variant}\n        data-over-quota={over || undefined}\n        className={cn(usageMeterVariants({ tone: paintedTone, size, variant }), className)}\n        {...props}\n      >\n        <div\n          data-slot=\"usage-meter-track\"\n          {...trackAria}\n          className=\"flex w-auto max-w-full min-w-0 flex-none items-center gap-(--usage-gutter)\"\n        >\n          {Array.from({ length: pips }, (_, index) => (\n            <span\n              key={index}\n              data-slot=\"usage-meter-pip\"\n              data-filled={index < lit}\n              className={cn(usageMeterPipVariants({ filled: index < lit, shape: \"tick\" }))}\n            />\n          ))}\n        </div>\n\n        {/* The same rule as pips for when the readout appears — a label, or\n            an exceeded quota — only placed after the strip instead of above\n            it. Over quota the percentage takes the ink tone: the plain\n            destructive fill is a 3.3:1 colour and this one is text. */}\n        {label != null || over ? (\n          <div\n            data-slot=\"usage-meter-label\"\n            className=\"flex min-w-0 items-baseline gap-2 text-sm leading-none\"\n          >\n            <span className=\"min-w-0 truncate font-medium tabular-nums text-foreground\">\n              {readout}\n              {label != null ? (\n                <span className=\"ml-1 font-normal text-muted-foreground\">{label}</span>\n              ) : null}\n            </span>\n            {over ? (\n              <span className=\"shrink-0 font-medium tabular-nums text-destructive-ink\">\n                {percent}%\n              </span>\n            ) : null}\n          </div>\n        ) : null}\n      </div>\n    )\n  }\n\n  if (variant === \"bar\") {\n    /* One line, broken where the caption sits: fill, caption, remainder, as a\n       three-column grid whose outer columns share the space left after the\n       caption in proportion to the reading. Their fr values always sum to 100\n       — a set of fr tracks summing below 1 would stop filling the row.\n\n       A part with nothing to show is dropped rather than drawn at 0fr, so 0%\n       does not open an empty gap before the caption and a spent quota does not\n       leave one after it. The same two edge rules as the pips hold, through a\n       min width on each part: anything spent shows a sliver of fill, and an\n       unspent quota keeps a sliver of remainder however close it gets. With\n       no quota at all there is nothing to fill, and the whole line is\n       remainder. The remainder takes the same stronger neutral as an unlit\n       tick: at 6px tall, `--muted` vanishes on the muted trays this sits in. */\n    const fillFr = quota == null ? 0 : Math.min(1, spent / quota) * 100\n    const restFr = 100 - fillFr\n    const columns = [\n      fillFr > 0 ? `${fillFr}fr` : null,\n      \"auto\",\n      restFr > 0 ? `${restFr}fr` : null,\n    ]\n      .filter(Boolean)\n      .join(\" \")\n\n    /* The caption's number follows the same honesty rule as the fill: a\n       percentage that rounds to 0 while something is spent reads \"<1%\", and\n       one that rounds to 100 while the quota is not yet spent reads \">99%\".\n       With no quota there is no percentage, so the caption states the count\n       instead — \"12 used\", never \"null% used\". Over quota it is the real\n       figure, \"112% used\". */\n    const figure =\n      quota == null || percent == null\n        ? formatCount(spent)\n        : spent > 0 && percent === 0\n          ? \"<1%\"\n          : !over && spent < quota && percent >= 100\n            ? \">99%\"\n            : `${percent}%`\n\n    const destructive = paintedTone === \"destructive\"\n\n    return (\n      <div\n        data-slot=\"usage-meter\"\n        data-tone={paintedTone}\n        data-size={size}\n        data-variant={variant}\n        data-over-quota={over || undefined}\n        className={cn(usageMeterVariants({ tone: paintedTone, size, variant }), className)}\n        {...props}\n      >\n        <div\n          data-slot=\"usage-meter-track\"\n          {...trackAria}\n          className=\"grid w-full min-w-0 items-center gap-2\"\n          style={{ gridTemplateColumns: columns }}\n        >\n          {fillFr > 0 ? (\n            <span\n              data-slot=\"usage-meter-fill\"\n              className=\"h-[calc(var(--usage-pip)/2)] min-w-1.5 rounded-full bg-current\"\n            />\n          ) : null}\n          <span\n            data-slot=\"usage-meter-caption\"\n            /* Muted beside the line, and the ink tone — not currentColor —\n               when the line has gone destructive: the destructive fill is a\n               3.3:1 colour, fine for a stroke and too light for text. */\n            className={cn(\n              \"whitespace-nowrap text-sm leading-none tabular-nums\",\n              destructive ? \"text-destructive-ink\" : \"text-muted-foreground\",\n            )}\n          >\n            {figure} {label ?? \"used\"}\n          </span>\n          {restFr > 0 ? (\n            <span\n              data-slot=\"usage-meter-rest\"\n              className=\"h-[calc(var(--usage-pip)/2)] min-w-1.5 rounded-full bg-muted-foreground/25\"\n            />\n          ) : null}\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <div\n      data-slot=\"usage-meter\"\n      /* The painted tone, not the requested one — a descendant selecting on\n         `data-tone` is matching what it can see, and `data-over-quota` says\n         why it changed. */\n      data-tone={paintedTone}\n      data-size={size}\n      data-over-quota={over || undefined}\n      className={cn(usageMeterVariants({ tone: paintedTone, size }), className)}\n      {...props}\n    >\n      {label != null || over ? (\n        <div\n          data-slot=\"usage-meter-label\"\n          /* Every colour in this row is stated outright. The root carries the\n             tone as a text colour for the pips to inherit, and a caption\n             painted lime is not a caption. */\n          className=\"flex items-baseline justify-between gap-2 text-sm leading-none\"\n        >\n          <span className=\"min-w-0 truncate font-medium tabular-nums text-foreground\">\n            {readout}\n            {label != null ? (\n              <span className=\"ml-1 font-normal text-muted-foreground\">{label}</span>\n            ) : null}\n          </span>\n          {over ? (\n            <span className=\"shrink-0 font-medium tabular-nums text-destructive\">\n              {percent}%\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n\n      <div\n        data-slot=\"usage-meter-track\"\n        {...trackAria}\n        className=\"flex w-full min-w-0 items-center gap-(--usage-gutter)\"\n      >\n        {/* A meter's children are presentational in ARIA, so the pips are\n            already outside the accessibility tree — `aria-hidden` here would\n            only restate that. */}\n        {Array.from({ length: pips }, (_, index) => (\n          <span\n            key={index}\n            data-slot=\"usage-meter-pip\"\n            data-filled={index < lit}\n            className={cn(usageMeterPipVariants({ filled: index < lit }))}\n          />\n        ))}\n      </div>\n    </div>\n  )\n}\n\nexport { UsageMeter, usageMeterPipVariants, usageMeterVariants }\nexport type { UsageMeterProps, UsageMeterSize, UsageMeterTone, UsageMeterVariant }\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}