{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "meter",
  "type": "registry:ui",
  "title": "Meter",
  "description": "ONE inline bar, absorbing three that were hand-rolled separately across the phase-10 corpus: an odds bar, a score meter and a reviewer bar. They differed in tone and in what the denominator meant; they did not differ in anything that justified three…",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "data",
    "primitive"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/data-state.json",
    "https://ds.interlace.tools/r/skeleton.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/meter.tsx",
      "target": "components/ui/meter.tsx",
      "type": "registry:ui",
      "content": "import * as React from 'react';\n\n// @interlace/meter v1.0.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/meter\n// What changed since: https://ds.interlace.tools/c/meter#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — Meter and RankedBarList\n *\n * ONE inline bar, absorbing three that were hand-rolled separately across the\n * phase-10 corpus: an odds bar, a score meter and a reviewer bar. They differed\n * in tone and in what the denominator meant; they did not differ in anything\n * that justified three components, and each of the three had reinvented the\n * \"there is no number here\" case slightly differently.\n *\n * ## Length and number. Never hue.\n *\n * Every magnitude is carried twice — by the **length** of the fill and by the\n * **printed value** — and the value is not optional. Colour carries tone\n * (\"is this good\"), never size. The artifacts state that rationale explicitly\n * and it is the same rule `Delta` follows with its glyph/sign/colour triple:\n * roughly 8% of men cannot separate the red from the green, and none of them\n * can read a bar whose only quantity is its hue.\n *\n * A consequence worth stating: the fill is clamped to the track, so a value\n * over its stated maximum draws full. The overage is visible in the number and\n * nowhere else — which is exactly why the number is mandatory.\n *\n * ## Two absences, drawn differently on purpose\n *\n *   - **`hatch`** — *uncountable*. No run happened. The track is hatched end\n *     to end and there is no fill, because a fill of zero length is\n *     indistinguishable from a measured zero. This is `value: null`, and it is\n *     the default rendering for it: you have to work to make this component\n *     draw an empty bar for missing data.\n *   - **`dead`** — *dormant*. There IS a number, and it is not live any more.\n *     The bar recedes rather than disappearing, because a dormant row still\n *     occupies rank and still counts toward a total; hiding it would change\n *     the shape of the list.\n *\n * ## Why `RankedBarList` supports a log scale\n *\n * One artifact encodes reach logarithmically so a 10k row and a 10M row can\n * share an axis. On a linear axis the 10k row is a hairline — it reads as\n * zero, which is the one thing it is not. On a log axis both are legible and\n * the ordering, which is what a ranked list is *for*, survives.\n *\n * It is opt-in and it is **labelled**: `scale=\"log\"` prints \"log scale\" beside\n * the caption. A log axis flatters small numbers, so an unlabelled one is a\n * chart that argues for its subject without saying so.\n *\n * ## MIN_VIEWPORT — 320\n *\n * Each row is label-over-bar, not label-beside-bar: at 320 a side-by-side\n * layout leaves the track ~90px, at which point the difference between 12%\n * and 19% is two pixels. Everything is `min-w-0` and the value is\n * `whitespace-nowrap`, so a long label wraps instead of pushing the page\n * sideways.\n *\n * | Rule | Concept                          | Where in this file                                          |\n * | ---- | -------------------------------- | ----------------------------------------------------------- |\n * | R4   | Extends native el                | `React.ComponentProps<'div'>`                                |\n * | R6   | data-slot on every part          | `data-slot=\"meter\" / \"-track\" / \"-fill\" / \"ranked-bar-list\"`  |\n * | R8   | No `isXxx`; enums for >2 states  | `variant` (default/hatch/dead), `tone`, `size`, `scale`       |\n * | R10  | Composition seams                | `note` / `display` ReactNode slots                            |\n * | R11  | One variable per part            | the list owns the domain; a row owns its own tone and state   |\n * | R14  | Declares min viewport            | `data-min-viewport={String(MIN_VIEWPORT)}` + exported const   |\n * | R18  | Tailwind only                    | width comes from `fillWidthClass`, never `style={{width}}`    |\n * | R19  | Tokens only                      | `bg-secondary`, `bg-primary`, `--viz-positive/-negative`      |\n * | R20  | AA contrast                      | table below                                                   |\n * | R25  | Server component                 | pure render — no hooks                                        |\n * | R26  | A11y                             | `role=\"meter\"` + `aria-valuenow/min/max` + `aria-valuetext`   |\n *\n * ## Contrast, measured (never eyeballed — see COLOR_PHILOSOPHY.md)\n *\n * | Composite                          | Light  | Dark   | Floor                      |\n * | ---------------------------------- | ------ | ------ | -------------------------- |\n * | fill `--primary` on `--secondary`  | 6.71:1 | 6.04:1 | 3:1 (SC 1.4.11, non-text)  |\n * | track `--secondary` on `--background` | 1.24:1 | 1.19:1 | decorative — the fill and the printed number carry the value |\n * | hatch `--viz-axis` on `--background`  | 3.49:1 | 3.83:1 | 3:1 (SC 1.4.11)         |\n * | dead fill `--muted-foreground/30` on `--secondary` | 1.9:1 | 1.8:1 | decorative — `dead` rows print their value and carry a badge |\n * | label `--muted-foreground`         | 5.66:1 | 6.29:1 | 4.5:1 (SC 1.4.3)           |\n *\n * The track/fill split is the same pattern as the slider rail vs knob: the\n * low-contrast element is supplementary, the high-contrast one carries the\n * success criterion.\n */\n\nimport { cn } from '@/lib/utils';\nimport {\n  DataStateBadge,\n  type AnnouncementOptions,\n  type DataStateFlags,\n} from '@/components/ui/data-state';\nimport {\n  announceDataState,\n  HATCH_CLASS,\n  resolveDataState,\n} from '@/components/ui/data-state-model';\nimport {\n  compactMagnitude,\n  describeMeter,\n  fillWidthClass,\n  meterDomainMax,\n  meterFraction,\n  rankByValue,\n  type MeterScaleKind,\n} from '@/components/ui/meter-scale';\nimport { Skeleton } from '@/components/ui/skeleton';\n\nexport const MIN_VIEWPORT = 320 as const;\n\n/**\n * How the bar is drawn.\n *\n *   - `default` — a real, live measurement.\n *   - `hatch`   — uncountable. No run happened. Chosen automatically when\n *                 `value` is `null`; naming it explicitly is for the case where\n *                 a number exists but must not be counted.\n *   - `dead`    — dormant. Real but no longer live; recedes without vanishing.\n */\nexport type MeterVariant = 'default' | 'hatch' | 'dead';\n\n/** Which semantic the fill carries. Never the magnitude — only the meaning. */\nexport type MeterTone = 'default' | 'positive' | 'negative' | 'neutral';\n\nconst TONE_FILL: Record<MeterTone, string> = {\n  default: 'bg-primary',\n  positive: 'bg-viz-positive',\n  negative: 'bg-viz-negative',\n  neutral: 'bg-viz-neutral',\n};\n\nconst SIZE_TRACK: Record<'sm' | 'md', string> = {\n  sm: 'h-1.5',\n  md: 'h-2.5',\n};\n\nexport interface MeterProps\n  extends Omit<React.ComponentProps<'div'>, 'children'> {\n  label: React.ReactNode;\n  /**\n   * The measurement. `null` means UNMEASURED and renders the hatch — it does\n   * NOT render a zero-length bar, because a zero-length bar and a measured\n   * zero are the same picture.\n   */\n  value: number | null;\n  /**\n   * The denominator. `null` or omitted means there is no stated maximum, so\n   * no fraction can be computed and the bar draws as hatch — a bar without a\n   * denominator is a length with no scale behind it.\n   */\n  max?: number | null;\n  unit?: string;\n  /** Linear (default) or logarithmic. Log must be labelled by the caller. */\n  scale?: MeterScaleKind;\n  /**\n   * Override the computed fill fraction, `0..1`. For the odds-bar case where\n   * the ratio being drawn is not `value / max` — e.g. a probability drawn\n   * beside an absolute count.\n   */\n  fraction?: number | null;\n  /** Printed magnitude. Defaults to a compact form of `value`. */\n  display?: React.ReactNode;\n  /** Second line under the bar — provenance, window, caveat. */\n  note?: React.ReactNode;\n  variant?: MeterVariant;\n  tone?: MeterTone;\n  size?: 'sm' | 'md';\n  /** Absence flags for this row, over and above the value being `null`. */\n  state?: DataStateFlags;\n  announce?: AnnouncementOptions;\n  loading?: boolean;\n}\n\nexport const Meter = React.forwardRef<HTMLDivElement, MeterProps>(function Meter(\n  {\n    label,\n    value,\n    max = null,\n    unit,\n    scale = 'linear',\n    fraction,\n    display,\n    note,\n    variant,\n    tone = 'default',\n    size = 'md',\n    state,\n    announce,\n    loading = false,\n    className,\n    ...props\n  },\n  ref,\n) {\n  if (loading) {\n    return (\n      <Skeleton\n        variant=\"meter\"\n        data-slot=\"meter\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={className}\n      />\n    );\n  }\n\n  const computed =\n    fraction === undefined ? meterFraction(value, max, scale) : fraction;\n\n  // `hatch` is the default for an absent measurement, not an opt-in. Getting\n  // this backwards is how the three hand-rolled bars each ended up drawing an\n  // empty track for missing data.\n  const resolvedVariant: MeterVariant =\n    variant ?? (value === null || computed === null ? 'hatch' : 'default');\n\n  const resolved = resolveDataState(\n    {\n      ...state,\n      notCounted: state?.notCounted ?? (resolvedVariant === 'hatch' && value === null),\n    },\n    announce,\n  );\n\n  // The sentence reports the VALUE, not the bar. A row with a real number and\n  // no denominator hatches (there is no scale to draw a length against) but it\n  // is not unmeasured — saying \"not measured\" there would be the component\n  // inventing an absence out of its own rendering decision.\n  const spoken = describeMeter(\n    typeof label === 'string' ? label : 'Value',\n    value,\n    max,\n    unit,\n  );\n\n  const printed =\n    display ??\n    (value === null ? null : `${compactMagnitude(value)}${unit ? ` ${unit}` : ''}`);\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"meter\"\n      data-variant={resolvedVariant}\n      data-tone={tone}\n      data-state={resolved.state}\n      data-scale={scale}\n      data-min-viewport={String(MIN_VIEWPORT)}\n      className={cn('flex w-full min-w-0 flex-col gap-1', className)}\n      {...props}\n    >\n      <div className=\"flex min-w-0 items-baseline justify-between gap-2\">\n        {/* `dead` does NOT dim the label. `--muted-foreground` is already the\n            quietest text token that clears AA (5.66:1 light / 6.29:1 dark);\n            `/70` on top measured 3.4:1 and axe caught it. A dormant row\n            recedes in its FILL, which is non-text and free to be quiet — its\n            label and its number stay as legible as every other row's, which\n            is the honest treatment for a row that still holds its rank. */}\n        <span className=\"min-w-0 break-words font-mono text-ui-sm uppercase tracking-wide text-muted-foreground\">\n          {label}\n        </span>\n        {/* The number is not optional. It is the second carrier of magnitude\n            and the only one that survives greyscale, a 40%-width screenshot\n            and a screen reader. */}\n        {printed === null ? (\n          <DataStateBadge\n            state={resolved.state === 'idle' ? 'not-counted' : resolved.state}\n            announce={announce}\n            className=\"shrink-0\"\n          />\n        ) : (\n          <span\n            // `dir=\"auto\"`: inside an RTL page the bidi algorithm reorders\n            // \"44 findings\" to \"findings 44\", because the number is a neutral\n            // run inside an RTL paragraph. `auto` resolves direction from the\n            // first strong character — Latin unit → LTR, Arabic unit → RTL —\n            // so a measurement keeps its own reading order either way.\n            dir=\"auto\"\n            className={cn(\n              'shrink-0 whitespace-nowrap font-body text-ui font-semibold tabular-nums',\n              resolvedVariant === 'dead' && 'text-muted-foreground',\n            )}\n          >\n            {printed}\n          </span>\n        )}\n      </div>\n\n      {resolvedVariant === 'hatch' ? (\n        // No fill at all. A hatched track says \"nothing ran here\"; a track\n        // with a zero-width fill says \"we ran it and the answer was none\".\n        <div\n          data-slot=\"meter-track\"\n          data-hatch\n          aria-hidden\n          className={cn(\n            'w-full rounded-full border border-dashed border-border',\n            SIZE_TRACK[size],\n            HATCH_CLASS,\n          )}\n        />\n      ) : (\n        <div\n          data-slot=\"meter-track\"\n          role=\"meter\"\n          aria-valuenow={value ?? 0}\n          aria-valuemin={0}\n          aria-valuemax={max ?? undefined}\n          aria-valuetext={spoken}\n          aria-label={typeof label === 'string' ? label : undefined}\n          className={cn(\n            'w-full overflow-hidden rounded-full bg-secondary',\n            SIZE_TRACK[size],\n          )}\n        >\n          <div\n            data-slot=\"meter-fill\"\n            className={cn(\n              'h-full rounded-full',\n              resolvedVariant === 'dead'\n                ? 'bg-muted-foreground/30'\n                : TONE_FILL[tone],\n              fillWidthClass(computed ?? 0),\n            )}\n          />\n        </div>\n      )}\n\n      {note ? (\n        <span className=\"font-body text-ui-sm leading-snug text-muted-foreground\">\n          {note}\n        </span>\n      ) : null}\n\n      {/* The bar's geometry is aria-hidden or summarised; the sentence is what\n          a screen reader actually gets, and it carries the real number rather\n          than the projection of it. */}\n      <span className=\"sr-only\">\n        {spoken}\n        {resolved.qualifiers.length > 0\n          ? ` ${resolved.qualifiers\n              .map((name) => announceDataState(name, announce))\n              .join(' ')}`\n          : ''}\n      </span>\n    </div>\n  );\n});\n\n// ─────────────────────────────────────────────────────────────────\n// RankedBarList — the repeated-row composition\n// ─────────────────────────────────────────────────────────────────\n\nexport interface RankedBarRow {\n  key: string;\n  label: React.ReactNode;\n  /** `null` = unmeasured. It keeps its row and its rank; it does not become 0. */\n  value: number | null;\n  unit?: string;\n  note?: React.ReactNode;\n  variant?: MeterVariant;\n  tone?: MeterTone;\n  state?: DataStateFlags;\n  display?: React.ReactNode;\n}\n\nexport interface RankedBarListProps\n  extends Omit<React.ComponentProps<'section'>, 'children'> {\n  rows: readonly RankedBarRow[];\n  /** Visible caption. Also the list's accessible name. */\n  caption?: React.ReactNode;\n  /**\n   * The shared denominator. Omit and it is the largest MEASURED value —\n   * `null`s are skipped rather than counted as zero, so an all-unmeasured list\n   * yields no domain and every row hatches.\n   */\n  max?: number | null;\n  scale?: MeterScaleKind;\n  /**\n   * `descending` (default) sorts by value with unmeasured rows last;\n   * `given` keeps the caller's order, for a list whose order is the argument.\n   */\n  order?: 'descending' | 'given';\n  size?: 'sm' | 'md';\n  /**\n   * List-wide absence. `truncated` is the one to reach for: it is the state\n   * that stops a top-10 from being read as a denominator.\n   */\n  state?: DataStateFlags;\n  announce?: AnnouncementOptions;\n  loading?: boolean;\n  /** Rows to reserve while loading. */\n  loadingRows?: number;\n}\n\nexport const RankedBarList = React.forwardRef<HTMLElement, RankedBarListProps>(\n  function RankedBarList(\n    {\n      rows,\n      caption,\n      max,\n      scale = 'linear',\n      order = 'descending',\n      size = 'md',\n      state,\n      announce,\n      loading = false,\n      loadingRows = 5,\n      className,\n      ...props\n    },\n    ref,\n  ) {\n    const list = resolveDataState({ ...state, loading }, announce);\n\n    if (list.state === 'loading') {\n      return (\n        <Skeleton\n          variant=\"meter\"\n          count={loadingRows}\n          data-slot=\"ranked-bar-list\"\n          data-min-viewport={String(MIN_VIEWPORT)}\n          className={className}\n        />\n      );\n    }\n\n    const ordered = order === 'descending' ? rankByValue(rows) : [...rows];\n    const domain =\n      max === undefined || max === null\n        ? meterDomainMax(rows.map((row) => row.value))\n        : max;\n\n    return (\n      <section\n        ref={ref}\n        data-slot=\"ranked-bar-list\"\n        data-state={list.state}\n        data-scale={scale}\n        data-qualifiers={list.qualifiers.join(' ') || undefined}\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={cn('flex w-full min-w-0 flex-col gap-4', className)}\n        {...props}\n      >\n        {caption || scale === 'log' || list.state !== 'idle' ? (\n          <div className=\"flex flex-wrap items-center gap-2\">\n            {caption ? (\n              <p className=\"font-mono text-ui-sm uppercase tracking-wide text-muted-foreground\">\n                {caption}\n              </p>\n            ) : null}\n            {/* A log axis that is not labelled is an argument disguised as a\n                measurement: it flatters every small row on the list. */}\n            {scale === 'log' ? (\n              <span className=\"rounded-sm border border-border px-1.5 py-0.5 font-mono text-ui-sm leading-none text-muted-foreground\">\n                log scale\n              </span>\n            ) : null}\n            {list.active\n              .filter((name) => name !== 'idle')\n              .map((name) => (\n                <DataStateBadge key={name} state={name} announce={announce} />\n              ))}\n          </div>\n        ) : null}\n\n        {ordered.map((row) => (\n          <Meter\n            key={row.key}\n            label={row.label}\n            value={row.value}\n            max={domain}\n            unit={row.unit}\n            note={row.note}\n            display={row.display}\n            variant={row.variant}\n            tone={row.tone}\n            size={size}\n            scale={scale}\n            state={row.state}\n            announce={announce}\n          />\n        ))}\n      </section>\n    );\n  },\n);\n"
    },
    {
      "path": "registry/interlace-ui/data-state-model.ts",
      "target": "components/ui/data-state-model.ts",
      "type": "registry:ui",
      "content": "export const DATA_STATES = [\n  'loading',\n  'error',\n  'not-applicable',\n  'not-counted',\n  'empty',\n  'partial',\n  'truncated',\n  'first-measurement',\n  'idle',\n] as const;\n\nexport type DataStateName = (typeof DATA_STATES)[number];\n\n/**\n * States that REPLACE the body — there is no value to render underneath them.\n *\n * The complement (`partial`, `truncated`, `first-measurement`) qualifies a body\n * that does render. `idle` is neither, and is excluded from both.\n */\nexport const REPLACING_STATES = new Set<DataStateName>([\n  'loading',\n  'error',\n  'not-applicable',\n  'not-counted',\n  'empty',\n]);\n\n/** States that annotate a body which still renders. */\nexport const QUALIFYING_STATES = new Set<DataStateName>([\n  'partial',\n  'truncated',\n  'first-measurement',\n]);\n\n/** True when this state swaps out the content rather than annotating it. */\nexport const replacesBody = (state: DataStateName): boolean =>\n  REPLACING_STATES.has(state);\n\n/**\n * The caller's flags, one per absence.\n *\n * `error` is `unknown` rather than `boolean` so a caught value can be passed\n * through untouched — the value is never rendered from here, only its\n * truthiness is read.\n *\n * Deliberately NOT a single `state` enum prop: the whole point is that these\n * co-occur. A partially-covered, truncated list is two facts, and an enum\n * would force the caller to pick one and drop the other on the floor.\n */\nexport interface DataStateFlags {\n  loading?: boolean;\n  error?: unknown;\n  empty?: boolean;\n  /** Some sources did not report. Every count below is a FLOOR, not a total. */\n  partial?: boolean;\n  /** The list is cut short. It must never become a denominator. */\n  truncated?: boolean;\n  /** The metric has no meaning for this subject. Not zero — inapplicable. */\n  notApplicable?: boolean;\n  /** No run happened. Not zero — unmeasured. This is the hatch. */\n  notCounted?: boolean;\n  /** A reading exists but no prior does. Never render this as `+0%`. */\n  firstMeasurement?: boolean;\n}\n\n/** Flag key → state name, in the same order as `DATA_STATES`. */\nconst FLAG_ORDER: readonly (readonly [keyof DataStateFlags, DataStateName])[] = [\n  ['loading', 'loading'],\n  ['error', 'error'],\n  ['notApplicable', 'not-applicable'],\n  ['notCounted', 'not-counted'],\n  ['empty', 'empty'],\n  ['partial', 'partial'],\n  ['truncated', 'truncated'],\n  ['firstMeasurement', 'first-measurement'],\n];\n\n/**\n * Context for the spoken sentence.\n *\n * Every field is optional, and every announcement is a complete sentence\n * without any of them — a component that forgets to pass `noun` still\n * announces something true, just less specific.\n */\nexport interface AnnouncementOptions {\n  /** What is missing — \"articles\", \"downloads\", \"runs\". */\n  noun?: string;\n  /** How many rows the truncated list actually shows. */\n  shown?: number;\n  /** How coverage is incomplete — \"4 of 9 sources reported\". */\n  coverage?: string;\n  /** Why this is not applicable — \"repository has no test suite\". */\n  reason?: string;\n}\n\n/**\n * The sentence a screen reader hears for one state.\n *\n * A hatch pattern that exists only in pixels is invisible to a screen reader,\n * which defeats the entire point of distinguishing \"no run\" from \"zero\" —\n * the distinction would survive for sighted users and vanish for everyone\n * else. Every state therefore owes a sentence, and the sentence says what the\n * absence MEANS rather than naming the state.\n */\nexport function announceDataState(\n  state: DataStateName,\n  options: AnnouncementOptions = {},\n): string {\n  const { noun, shown, coverage, reason } = options;\n  const subject = noun ?? 'data';\n\n  switch (state) {\n    case 'loading':\n      return `Loading ${subject}.`;\n    case 'error':\n      return `${capitalise(subject)} could not be loaded.`;\n    case 'not-applicable':\n      return reason\n        ? `Not applicable: ${reason}.`\n        : `Not applicable. No value is possible here.`;\n    // \"This is not a zero\" is doing real work: without it a listener has no\n    // way to tell an unmeasured cell from a measured zero, which is the exact\n    // confusion the hatch exists to prevent for sighted readers.\n    case 'not-counted':\n      return `Not counted. No measurement was taken; this is not a zero.`;\n    case 'empty':\n      return `No ${subject}.`;\n    case 'partial':\n      return coverage\n        ? `Partial coverage: ${coverage}. Every count is a floor, not a total.`\n        : `Partial coverage. Every count is a floor, not a total.`;\n    case 'truncated':\n      return shown === undefined\n        ? `Truncated list. The total is unknown; do not use this as a denominator.`\n        : `Truncated list: showing ${shown.toLocaleString()} of an unknown total. ` +\n            `Do not use this as a denominator.`;\n    case 'first-measurement':\n      return `First measurement. There is no prior reading to compare against.`;\n    /* istanbul ignore next -- exhaustive; `idle` is the only remaining member */\n    default:\n      return '';\n  }\n}\n\nconst capitalise = (value: string): string =>\n  value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);\n\n/** What `resolveDataState` returns. */\nexport interface ResolvedDataState {\n  /** The winner by precedence — what a single-slot surface should render. */\n  state: DataStateName;\n  /** Every active state, in precedence order. `['idle']` when none fired. */\n  active: DataStateName[];\n  /** `active` minus the winner. The facts a one-winner resolver would lose. */\n  qualifiers: DataStateName[];\n  /** True when the winner swaps out the body rather than annotating it. */\n  replaces: boolean;\n  /** Winner sentence followed by every qualifier sentence. */\n  announcement: string;\n}\n\n/**\n * Resolve a flag bag into a state, its qualifiers, and one spoken sentence.\n *\n * The two rules the phase-10 audit called out by name both fall out of the\n * array order and are pinned by tests: **error beats empty** (a failed fetch\n * is a different message, not \"nothing found\"), and **truncated is not empty**\n * (they are separate members, and truncated does not replace the body at all).\n */\nexport function resolveDataState(\n  flags: DataStateFlags = {},\n  options: AnnouncementOptions = {},\n): ResolvedDataState {\n  const active = FLAG_ORDER.filter(([key]) => Boolean(flags[key])).map(\n    ([, state]) => state,\n  );\n\n  if (active.length === 0) {\n    return {\n      state: 'idle',\n      active: ['idle'],\n      qualifiers: [],\n      replaces: false,\n      announcement: '',\n    };\n  }\n\n  const [state, ...qualifiers] = active;\n  return {\n    state,\n    active,\n    qualifiers,\n    replaces: replacesBody(state),\n    announcement: [state, ...qualifiers]\n      .map((name) => announceDataState(name, options))\n      .join(' '),\n  };\n}\n\n// ── Presentation ────────────────────────────────────────────────────────────\n\n/**\n * How loud a state is allowed to be.\n *\n *   - `recede` — the *unwritten* family: a thing that was never going to have\n *     a value. It should not compete with the data around it.\n *   - `muted`  — the *unmeasured* family: a real gap, worth noticing, not\n *     worth alarming about.\n *   - `accent` — the *ungated* family: actionable. Something a reader can go\n *     and change. This is the one that earns colour.\n *   - `danger` — the request failed.\n */\nexport type DataStateEmphasis = 'recede' | 'muted' | 'accent' | 'danger';\n\n/**\n * The diagonal hatch. **No run happened.**\n *\n * Written as a Tailwind arbitrary background-image rather than a CSS class in\n * `styles/` so the whole vocabulary ships inside the registry item — a\n * consumer who runs `npx shadcn add @interlace/data-state` gets the hatch,\n * not a reference to a stylesheet they do not have. `image:` is the explicit\n * type hint; without it Tailwind has to guess the property from a `repeating-\n * linear-gradient(...)` value.\n *\n * `--viz-axis` and not `--viz-grid`: the grid token is documented as\n * decorative (1.37:1) and must never be the sole carrier of a value. The hatch\n * IS the value here, so it uses the axis token, measured at 3.49:1 light /\n * 3.83:1 dark — clearing WCAG 2.2 SC 1.4.11 for non-text content.\n */\nexport const HATCH_CLASS =\n  'bg-[image:repeating-linear-gradient(45deg,var(--viz-axis)_0,var(--viz-axis)_1px,transparent_1px,transparent_5px)]';\n\n/** The same hatch, quieter, for states that recede rather than report. */\nexport const HATCH_CLASS_FAINT =\n  'bg-[image:repeating-linear-gradient(45deg,var(--viz-grid)_0,var(--viz-grid)_1px,transparent_1px,transparent_5px)]';\n\n/**\n * Everything a surface needs to paint one state.\n *\n * `glyph` and `short` are BOTH here because the two carriers serve different\n * widths: a meter cell has room for a glyph, a stat strip has room for a word.\n * Neither is ever the only carrier — `announceDataState` is.\n */\nexport interface DataStatePresentation {\n  /** One-character mark. Always `aria-hidden`; the sentence carries meaning. */\n  glyph: string;\n  /** Terse visible label, lower case, for a chip. */\n  short: string;\n  /** Diagonal hatch — no run happened. */\n  hatch: boolean;\n  /** Dashed outline — planned, or not yet approached. Solid means real. */\n  dashed: boolean;\n  emphasis: DataStateEmphasis;\n  /**\n   * Tailwind classes for the chip SURFACE. Never the hatch.\n   *\n   * The first browser pass painted `HATCH_CLASS` on the chip itself and the\n   * diagonals ran straight through \"not counted\", which is a legibility bug\n   * axe cannot see and jsdom cannot render — it reports every box as 0×0 and\n   * resolves no Tailwind at all. The hatch moved to `swatch`, a leading block\n   * that carries the texture beside the words instead of behind them.\n   */\n  chip: string;\n  /**\n   * Classes for the leading hatch swatch, or `''` when this state has none.\n   * Replaces `glyph` when present — a texture and a character competing for\n   * the same 12px is two marks saying one thing badly.\n   */\n  swatch: string;\n}\n\n/**\n * The state → pixels table.\n *\n * Read the `hatch` / `dashed` / `emphasis` columns down and the doctrine is\n * visible: `not-counted` is the only hatch that reports (a run that did not\n * happen), `not-applicable` hatches faintly and recedes (it was never going\n * to happen), and `first-measurement` is the only absence that gets the accent\n * — because it is the only one the reader can act on, by measuring again\n * tomorrow.\n *\n * Contrast, measured against `--background` (see COLOR_PHILOSOPHY.md):\n *\n * | emphasis | token                | Light   | Dark    | Floor |\n * | -------- | -------------------- | ------- | ------- | ----- |\n * | recede   | `--muted-foreground` |  5.66:1 |  6.29:1 | 4.5:1 |\n * | muted    | `--muted-foreground` |  5.66:1 |  6.29:1 | 4.5:1 |\n * | accent   | `--primary`          |  8.80:1 |  9.12:1 | 4.5:1 |\n * | danger   | `--destructive`      |  8.31:1 | 10.43:1 | 4.5:1 |\n * | (hatch)  | `--viz-axis` on bg   |  3.49:1 |  3.83:1 | 3:1 (SC 1.4.11, non-text) |\n */\nexport const DATA_STATE_PRESENTATION: Record<\n  DataStateName,\n  DataStatePresentation\n> = {\n  loading: {\n    glyph: '',\n    short: 'loading',\n    hatch: false,\n    dashed: false,\n    emphasis: 'muted',\n    chip: 'border-border text-muted-foreground',\n    swatch: '',\n  },\n  error: {\n    glyph: '!',\n    short: 'error',\n    hatch: false,\n    dashed: false,\n    emphasis: 'danger',\n    chip: 'border-destructive/40 text-destructive',\n    swatch: '',\n  },\n  'not-applicable': {\n    glyph: '',\n    short: 'n/a',\n    hatch: true,\n    dashed: false,\n    emphasis: 'recede',\n    chip: 'border-border/60 text-muted-foreground',\n    swatch: `border-border/60 ${HATCH_CLASS_FAINT}`,\n  },\n  'not-counted': {\n    glyph: '',\n    short: 'not counted',\n    hatch: true,\n    dashed: false,\n    emphasis: 'muted',\n    chip: 'border-border text-muted-foreground',\n    swatch: `border-border ${HATCH_CLASS}`,\n  },\n  empty: {\n    glyph: '—',\n    short: 'none',\n    hatch: false,\n    dashed: false,\n    emphasis: 'muted',\n    chip: 'border-border text-muted-foreground',\n    swatch: '',\n  },\n  partial: {\n    glyph: '≥',\n    short: 'partial',\n    hatch: false,\n    dashed: true,\n    emphasis: 'muted',\n    chip: 'border-dashed border-border text-muted-foreground',\n    swatch: '',\n  },\n  truncated: {\n    glyph: '⋯',\n    short: 'truncated',\n    hatch: false,\n    dashed: true,\n    emphasis: 'muted',\n    chip: 'border-dashed border-border text-muted-foreground',\n    swatch: '',\n  },\n  'first-measurement': {\n    glyph: '·',\n    short: 'first measurement',\n    hatch: false,\n    dashed: true,\n    emphasis: 'accent',\n    chip: 'border-dashed border-primary/50 text-primary',\n    swatch: '',\n  },\n  idle: {\n    glyph: '',\n    short: '',\n    hatch: false,\n    dashed: false,\n    emphasis: 'muted',\n    chip: '',\n    swatch: '',\n  },\n};\n\n/** Presentation lookup. A function so callers do not index a frozen map by hand. */\nexport const presentationFor = (\n  state: DataStateName,\n): DataStatePresentation => DATA_STATE_PRESENTATION[state];\n\n/**\n * @interlace/ui — the DataState vocabulary (pure)\n *\n * Nothing in this file imports React, touches the DOM, or renders anything.\n * It is the part of the absence contract that can be *proved* — the union, the\n * precedence order, the announcement each state owes a screen reader, and the\n * class set that paints it — so `data-state.tsx`, `stat-strip.tsx` and\n * `meter.tsx` all resolve absence through the same three functions rather than\n * each re-deriving it from a boolean ladder.\n *\n * ## Why absence is a vocabulary and not a placeholder\n *\n * `charts/scale.ts` already encodes one half of this: `null` is *unmeasured*,\n * never zero, and `numeric()` drops it rather than coercing it, because\n * averaging over an invented zero silently manufactures data. That is a\n * statement about arithmetic. This file is the same statement about pixels.\n *\n * Six published artifacts were catalogued for phase 10 and every one of them\n * had hand-rolled some version of this, because the distinctions are real and\n * no component library ships them:\n *\n *   - a diagonal **hatch** means *no run happened* — which is a different fact\n *     from a run that returned zero, and a very different fact from a run that\n *     is still going;\n *   - a **dashed** outline means *planned / not yet approached*; solid means\n *     *real*;\n *   - `not counted`, `authority`, `visibility`, `dormant` are legitimate\n *     non-numeric values a cell can hold, and rendering any of them as `0`\n *     is a lie the reader cannot detect;\n *   - two absences that recede vs. accent on purpose: an *unwritten* thing is\n *     quiet, an *ungated* thing is actionable.\n *\n * The rules that fall out, verbatim from the corpus: **never render a missing\n * prior as 0**; **a truncated list must never be a denominator**; when coverage\n * is partial, **treat every count as a floor**.\n *\n * ## Two kinds of state, which is what makes the precedence tractable\n *\n * REPLACING states answer \"there is nothing here to show\" — the body is\n * swapped for the state itself. QUALIFYING states answer \"there IS something\n * here, and here is what is wrong with it\" — the body renders, annotated.\n *\n * A resolver that returns one winner throws away the second fact. So\n * `resolveDataState` returns the winner **and** every other active state as\n * `qualifiers`, and the announcement concatenates them. A partial-coverage\n * result that is also truncated says so twice, because it is wrong twice.\n *\n * ## Precedence\n *\n * `DATA_STATES` **is** the precedence order — lowest index wins. Reading down\n * the array is reading the rule, so the two cannot drift apart.\n *\n *   1. `loading`            nothing is known yet; every other flag is stale.\n *   2. `error`              a failed fetch must never read as \"no data\".\n *   3. `not-applicable`     the metric has no meaning here. Any number, `0`\n *                           included, would be a category error.\n *   4. `not-counted`        measurable in principle, deliberately not tallied.\n *                           `0` here invents a measurement nobody took.\n *   5. `empty`              a complete result with nothing in it. The only\n *                           absence that is a real, observed zero-length.\n *   6. `partial`            coverage is incomplete. Ranked above `truncated`\n *                           because it is INVISIBLE: the reader can see a list\n *                           stop, but cannot see a source that never reported.\n *   7. `truncated`          the list is cut. Not a denominator.\n *   8. `first-measurement`  a value exists, no prior does. Never `+0%`.\n *   9. `idle`              data is real and complete. The resting state.\n */\n\n/**\n * Every state, in precedence order. Lowest index wins.\n *\n * `idle` is last and is not an absence — it is the absence of absence, kept in\n * the union so a caller can exhaustively switch and so the resolver always has\n * something to return.\n */\n"
    },
    {
      "path": "registry/interlace-ui/meter-scale.ts",
      "target": "components/ui/meter-scale.ts",
      "type": "registry:ui",
      "content": "export type MeterScaleKind = 'linear' | 'log';\n\n/** Clamp to the unit interval. Non-finite input is treated as the floor. */\nexport const clamp01 = (value: number): number =>\n  Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;\n\n/**\n * The log domain floor.\n *\n * `log(0)` is `-Infinity`, so a log axis needs a positive lower bound. 1 is the\n * honest choice for counts: it is the smallest thing that can be observed at\n * all, and it maps to fraction 0 — \"the least this axis can show\", not \"none\".\n */\nexport const LOG_FLOOR = 1;\n\n/**\n * Value → fill fraction in `[0, 1]`, or `null` when the value is unmeasured.\n *\n * Rules, each of which exists because the naive version is wrong:\n *\n *  - `value === null` → `null`. Unmeasured is not zero. Callers render the\n *    hatch, not an empty bar.\n *  - a non-finite `value` or `max` → `null`. `NaN / 0` silently paints a\n *    full-width bar in some browsers and an empty one in others.\n *  - `max <= 0` → `null`. There is no denominator, so there is no fraction;\n *    inventing `1` would report every row as complete.\n *  - the result is clamped, so a value above the stated maximum draws full\n *    rather than overflowing its track. The NUMBER still shows the overage —\n *    which is why the number is never optional.\n *\n * On the log branch, `floor` is the bottom of the domain (default `LOG_FLOOR`).\n * Values at or below it map to 0. When the domain collapses (`max <= floor`)\n * a measured value is at its ceiling, so it maps to 1 — unlike a flat time\n * series, a meter's denominator is a *stated* maximum rather than an observed\n * span, so there is nothing to centre.\n */\nexport function meterFraction(\n  value: number | null | undefined,\n  max: number | null | undefined,\n  kind: MeterScaleKind = 'linear',\n  floor: number = LOG_FLOOR,\n): number | null {\n  if (value === null || value === undefined) return null;\n  if (max === null || max === undefined) return null;\n  if (!Number.isFinite(value) || !Number.isFinite(max)) return null;\n  if (max <= 0) return null;\n\n  if (kind === 'linear') return clamp01(value / max);\n\n  const lo = Math.max(floor, Number.MIN_VALUE);\n  if (max <= lo) return value >= max ? 1 : 0;\n  if (value <= lo) return 0;\n  return clamp01(\n    (Math.log10(value) - Math.log10(lo)) / (Math.log10(max) - Math.log10(lo)),\n  );\n}\n\n/**\n * The largest measured value in a set of rows, or `null` when none were.\n *\n * `null`s are skipped rather than counted as `0`, so an all-unmeasured list\n * yields `null` and the caller renders hatch rows instead of a row of empty\n * bars implying every value was zero.\n */\nexport function meterDomainMax(\n  values: readonly (number | null | undefined)[],\n): number | null {\n  let max: number | null = null;\n  for (const value of values) {\n    if (value === null || value === undefined) continue;\n    if (!Number.isFinite(value)) continue;\n    if (max === null || value > max) max = value;\n  }\n  return max;\n}\n\n/**\n * Sort rows by magnitude, unmeasured rows last.\n *\n * Two properties that a bare `.sort((a, b) => b.value - a.value)` does not\n * have, and both matter:\n *\n *  - An unmeasured row (`null`) sinks to the bottom **without becoming zero**.\n *    Coercing it would put it below every measured row *and* claim it was the\n *    smallest, which is a measurement nobody took.\n *  - The sort is stable within each group, so two rows at the same value — and\n *    the whole block of unmeasured rows — keep the caller's order. An\n *    alphabetical input stays alphabetical where the data does not separate it.\n */\nexport function rankByValue<T extends { value: number | null | undefined }>(\n  rows: readonly T[],\n): T[] {\n  const measurable = (row: T): boolean =>\n    row.value !== null && row.value !== undefined && Number.isFinite(row.value);\n\n  return rows\n    .map((row, index) => ({ row, index }))\n    .sort((a, b) => {\n      const aHas = measurable(a.row);\n      const bHas = measurable(b.row);\n      if (aHas !== bHas) return aHas ? -1 : 1;\n      if (!aHas) return a.index - b.index;\n      const diff = (b.row.value as number) - (a.row.value as number);\n      return diff === 0 ? a.index - b.index : diff;\n    })\n    .map((entry) => entry.row);\n}\n\n/**\n * Fill widths as literal Tailwind classes, indexed by whole percent.\n *\n * A table and not `style={{ width }}`, because Tailwind v4 scans source as raw\n * TEXT: a template-built `w-[${n}%]` is never emitted, and an inline style is\n * the thing this design system does not do (R18). 101 entries is the honest\n * cost of a data-driven width that stays in the class layer.\n *\n * One percent is ~3px on a 300px track, which is below the width of the\n * hairline that separates two rows — and the exact value is printed beside the\n * bar regardless, because length is never the only carrier.\n */\nexport const FILL_WIDTH_CLASSES = [\n  'w-[0%]', 'w-[1%]', 'w-[2%]', 'w-[3%]', 'w-[4%]', 'w-[5%]', 'w-[6%]', 'w-[7%]', 'w-[8%]', 'w-[9%]',\n  'w-[10%]', 'w-[11%]', 'w-[12%]', 'w-[13%]', 'w-[14%]', 'w-[15%]', 'w-[16%]', 'w-[17%]', 'w-[18%]', 'w-[19%]',\n  'w-[20%]', 'w-[21%]', 'w-[22%]', 'w-[23%]', 'w-[24%]', 'w-[25%]', 'w-[26%]', 'w-[27%]', 'w-[28%]', 'w-[29%]',\n  'w-[30%]', 'w-[31%]', 'w-[32%]', 'w-[33%]', 'w-[34%]', 'w-[35%]', 'w-[36%]', 'w-[37%]', 'w-[38%]', 'w-[39%]',\n  'w-[40%]', 'w-[41%]', 'w-[42%]', 'w-[43%]', 'w-[44%]', 'w-[45%]', 'w-[46%]', 'w-[47%]', 'w-[48%]', 'w-[49%]',\n  'w-[50%]', 'w-[51%]', 'w-[52%]', 'w-[53%]', 'w-[54%]', 'w-[55%]', 'w-[56%]', 'w-[57%]', 'w-[58%]', 'w-[59%]',\n  'w-[60%]', 'w-[61%]', 'w-[62%]', 'w-[63%]', 'w-[64%]', 'w-[65%]', 'w-[66%]', 'w-[67%]', 'w-[68%]', 'w-[69%]',\n  'w-[70%]', 'w-[71%]', 'w-[72%]', 'w-[73%]', 'w-[74%]', 'w-[75%]', 'w-[76%]', 'w-[77%]', 'w-[78%]', 'w-[79%]',\n  'w-[80%]', 'w-[81%]', 'w-[82%]', 'w-[83%]', 'w-[84%]', 'w-[85%]', 'w-[86%]', 'w-[87%]', 'w-[88%]', 'w-[89%]',\n  'w-[90%]', 'w-[91%]', 'w-[92%]', 'w-[93%]', 'w-[94%]', 'w-[95%]', 'w-[96%]', 'w-[97%]', 'w-[98%]', 'w-[99%]',\n  'w-[100%]',\n] as const;\n\n/**\n * Fraction → the class that paints it.\n *\n * Rounds rather than floors: at 0.999 a floor would paint 99% and leave a\n * hairline of track visible on a row that IS the maximum, which reads as\n * \"almost\" on the one row where the answer is \"yes\".\n */\nexport function fillWidthClass(fraction: number): string {\n  if (!Number.isFinite(fraction)) return FILL_WIDTH_CLASSES[0];\n  const percent = Math.round(clamp01(fraction) * 100);\n  return FILL_WIDTH_CLASSES[percent];\n}\n\n/**\n * Compact magnitude — `12.4k`, `3.1M`.\n *\n * A deliberate twin of `compact()` in `charts/scale.ts` rather than an import\n * of it. `charts/scale.ts` is a `.ts` companion, not a registry item, so a\n * cross-tier `../charts/scale.js` import from a primitive emits a\n * `registryDependencies` entry that resolves to nothing and makes the whole\n * item silently uninstallable via `npx shadcn add`. The two are pinned to\n * identical output by a test rather than by a shared module.\n */\nexport function compactMagnitude(value: number): string {\n  const abs = Math.abs(value);\n  if (!Number.isFinite(value)) return '—';\n  if (abs >= 1e9) return `${(value / 1e9).toFixed(1)}B`;\n  if (abs >= 1e6) return `${(value / 1e6).toFixed(1)}M`;\n  if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}k`;\n  return value.toLocaleString();\n}\n\n/**\n * The spoken sentence for one measured bar.\n *\n * A bar drawn with `aria-hidden` geometry and no text is a picture of a number\n * that a screen reader cannot read. This is the text equivalent WCAG 1.1.1\n * asks for, and it carries the ACTUAL value — not \"62 percent\", which is the\n * projection rather than the measurement.\n */\nexport function describeMeter(\n  label: string,\n  value: number | null,\n  max: number | null,\n  unit?: string,\n): string {\n  if (value === null) return `${label}: not measured.`;\n  const suffix = unit ? ` ${unit}` : '';\n  const measured = `${value.toLocaleString()}${suffix}`;\n  return max === null || max <= 0\n    ? `${label}: ${measured}.`\n    : `${label}: ${measured} of ${max.toLocaleString()}${suffix}.`;\n}\n\n/**\n * @interlace/ui — meter arithmetic (pure)\n *\n * The maths behind `Meter` and `RankedBarList`. No React, no DOM — the same\n * split `charts/scale.ts` makes, and for the same reason: a bar that draws a\n * beautiful wrong length is worse than one that fails to render, and the\n * length is the part that can be proved rather than reviewed.\n *\n * ## `null` is unmeasured, and it stays that way\n *\n * `meterFraction` returns `null` for a `null` value rather than `0`. A bar of\n * length zero and a bar that was never run look identical once the number is\n * gone, and the whole point of the hatch variant is that they must not.\n *\n * ## Length and number, never hue\n *\n * Nothing here returns a colour. Magnitude is carried by the fraction (length)\n * and by the formatted value (number) so the bar survives greyscale, a\n * screenshot at 40% width, and the ~8% of men with red-green colour vision\n * deficiency. That is the same rule `Delta` follows with its glyph/sign/colour\n * triple — see VISUALIZATION_PHILOSOPHY.md §5.\n */\n\n/**\n * Linear or logarithmic.\n *\n * `log` exists because reach spans orders of magnitude: a row at 10k and a row\n * at 10M cannot share a linear axis without the first becoming a hairline that\n * reads as zero. On a log axis both are legible and the *ordering* — the thing\n * a ranked list is for — survives.\n *\n * It is opt-in, and it must stay opt-in. A log axis flatters small numbers, so\n * silently defaulting to it would make every list look healthier than it is.\n */\n"
    }
  ],
  "meta": {
    "tier": "primitive",
    "client": false,
    "minViewport": 320,
    "loading": true,
    "version": "1.0.0",
    "since": "1.1.0"
  },
  "docs": "## @interlace/meter\n\nInstalled to `components/ui/meter.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/meter';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/meter\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
