{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stat-strip",
  "type": "registry:ui",
  "title": "Stat Strip",
  "description": "The measurement strip: a dense row of labelled numbers, each with an optional note and an optional change. It appeared in **all six** of the artifacts catalogued for phase 10, hand-rolled four separate times, and it is the smallest surface where the…",
  "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/stat-strip.tsx",
      "target": "components/ui/stat-strip.tsx",
      "type": "registry:ui",
      "content": "import * as React from 'react';\n\n// @interlace/stat-strip v1.0.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/stat-strip\n// What changed since: https://ds.interlace.tools/c/stat-strip#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — StatStrip\n *\n * The measurement strip: a dense row of labelled numbers, each with an\n * optional note and an optional change. It appeared in **all six** of the\n * artifacts catalogued for phase 10, hand-rolled four separate times, and it\n * is the smallest surface where the absence vocabulary earns its keep.\n *\n * ## Why it is not `StatGroup` with tighter padding\n *\n * `StatGroup` renders `StatCard`s: a card per metric, `p-5`, a heading-sized\n * value, a border all the way round. That is a dashboard hero — three to five\n * numbers, each of which is the point of the page.\n *\n * A strip is the opposite brief. Eight to twelve numbers, read as a set,\n * scanned rather than studied, and the comparison BETWEEN them is what the\n * reader came for. So: a mono micro-label (the eye finds a label by shape, not\n * by reading it), `tabular-nums` so the digit columns align down the strip,\n * and a rail instead of a border because eight boxes at this density is a\n * grid of frames with numbers trapped inside them.\n *\n * ## The three-state null, which is the entire reason this exists\n *\n * A metric can be in three genuinely different situations, and four hand-rolled\n * copies got at least one of them wrong:\n *\n *   | `value` | `prior`     | renders                                  |\n *   | ------- | ----------- | ---------------------------------------- |\n *   | number  | number      | the value, and the caller's `delta`      |\n *   | number  | `null`      | the value, and **\"first measurement\"**   |\n *   | number  | *omitted*   | the value, and nothing — no claim made   |\n *   | `null`  | anything    | a state badge — never `0`, never `—`     |\n *\n * `prior: null` is the load-bearing row. A metric with no prior reading has no\n * percentage change; it does not have a change of zero. Rendering `+0%` there\n * invents a measurement that was never taken and, worse, invents a *comparison*\n * — it tells the reader the number held steady when nobody was watching. The\n * strip refuses: `prior === null` suppresses the caller's `delta` node\n * entirely and shows the `first-measurement` badge, which is the one absence\n * that gets the accent colour because it is the one a reader can resolve, by\n * measuring again tomorrow.\n *\n * `value: null` is the other half, and it is the doctrine `charts/scale.ts`\n * already encodes: a day nobody measured is not a day the metric was zero.\n * Pass `state.notCounted` and the cell hatches.\n *\n * ## MIN_VIEWPORT — 320\n *\n * Two columns at 320, three from `sm`, the caller's count from `lg`. Every\n * cell is `min-w-0` and every value wraps, so a long label cannot push the\n * page sideways — the failure mode a `grid-cols-4` strip has on a phone.\n *\n * | Rule | Concept                          | Where in this file                                          |\n * | ---- | -------------------------------- | ----------------------------------------------------------- |\n * | R4   | Extends native el                | `React.ComponentProps<'section'>`                            |\n * | R6   | data-slot on every part          | `data-slot=\"stat-strip\" / \"-item\"` + `data-state`             |\n * | R7   | className merged + ...rest       | `cn(...)` + `{...props}`                                      |\n * | R8   | No `isXxx`; enum for cols        | `cols` is `2 | 3 | 4 | 5 | 6`; `tone` is a four-name enum      |\n * | R10  | Composition seams                | `delta` and `note` are ReactNode slots                        |\n * | R11  | One variable per part            | the strip owns the track count; an item owns its own state    |\n * | R14  | Declares min viewport            | `data-min-viewport={String(MIN_VIEWPORT)}` + exported const   |\n * | R18  | Tailwind only                    | zero inline `style`                                           |\n * | R19  | Tokens only                      | `border-border`, `text-muted-foreground`, `bg-primary`        |\n * | R20  | AA contrast                      | label `--muted-foreground` 5.66:1 light / 6.29:1 dark; rail is non-text |\n * | R25  | Server component                 | pure render — no hooks                                        |\n * | R26  | A11y                             | real `<dl>` pairs; every absence carries a sentence           |\n */\n\nimport { cn } from '@/lib/utils';\nimport {\n  DataStateBadge,\n  type AnnouncementOptions,\n  type DataStateFlags,\n} from '@/components/ui/data-state';\nimport { announceDataState, resolveDataState } from '@/components/ui/data-state-model';\nimport { Skeleton } from '@/components/ui/skeleton';\n\nexport const MIN_VIEWPORT = 320 as const;\n\n/**\n * Which semantic a number carries. Never its magnitude — only its meaning.\n *\n * The same four names as `MeterTone`, on purpose: a strip and a meter on one\n * page describing the same metric must not need two different words for \"this\n * is bad\".\n */\nexport type StatTone = 'default' | 'positive' | 'negative' | 'neutral';\n\n/** One measurement. */\nexport interface StatItem {\n  /** React key and `data-key`. */\n  key: string;\n  /** The micro-label. Short — it is set in mono caps and it is scanned. */\n  label: React.ReactNode;\n  /**\n   * The measurement. `null` means UNMEASURED, and the strip will not render it\n   * as `0` or as a bare dash — pass the matching flag in `state` so the cell\n   * says which kind of absence it is.\n   */\n  value?: number | string | null;\n  /** Suffix printed after the value — \"%\", \"ms\", \"/ day\". */\n  unit?: string;\n  /** A second line under the value. Provenance, window, caveat. */\n  note?: React.ReactNode;\n  /**\n   * The previous reading.\n   *\n   *   - omitted  → no comparison is claimed and none is drawn.\n   *   - `null`   → there IS no prior. Renders \"first measurement\" and\n   *                SUPPRESSES `delta`, so a missing prior can never surface\n   *                as `+0%`.\n   *   - a number → `delta` renders as supplied.\n   */\n  prior?: number | null;\n  /**\n   * The change, as a node — usually `<Delta points={…} />` from\n   * `charts/delta`. A slot rather than a computed value so the strip never has\n   * to own polarity, units, or the \"is up good here\" question.\n   */\n  delta?: React.ReactNode;\n  /**\n   * Whether this number is GOOD, and nothing else.\n   *\n   * The gap this closes: a strip could say what a number is and how it changed,\n   * and had no way at all to say **this one is bad**. A dashboard whose error\n   * budget is blown renders it in the same weight as the one that is fine, and\n   * the reader has to know the thresholds to see it — which is the entire job\n   * the strip was supposed to do.\n   *\n   * Same vocabulary as `MeterTone`, and the same rule: tone answers \"is this\n   * good\", NEVER \"how big\". Two different values at one tone differ only in the\n   * digits. Deliberately not derived from `delta` — up is good for downloads\n   * and bad for latency, and only the caller knows which this is (`Delta`\n   * already makes exactly this argument with its `polarity`).\n   *\n   * Three carriers, because colour alone fails a greyscale print and ~8% of\n   * men: the RAIL down the cell, the value's colour, and a sentence in the\n   * accessible name. If you find yourself dropping the sentence because the\n   * colour \"already says it\", it does not.\n   */\n  tone?: StatTone;\n  /** Absence flags for this one metric. */\n  state?: DataStateFlags;\n  /** Context for this metric's spoken sentences. */\n  announce?: AnnouncementOptions;\n}\n\n/**\n * Emphasis → the rail that runs down the left of a cell.\n *\n * A `border-inline-start` and not a rail `<span>`. The first pass drew the\n * rail as its own element, which forced the `<dt>`/`<dd>` pair one level\n * deeper — and the HTML spec allows exactly ONE wrapping `<div>` between a\n * `<dl>` and its items. Axe caught it (`definition-list` + `dlitem`, both\n * serious) where jsdom could not: jsdom renders the markup happily and has no\n * opinion about content models.\n */\nconst RAIL_CLASS = {\n  idle: 'border-border',\n  recede: 'border-muted-foreground/25',\n  muted: 'border-muted-foreground/50',\n  accent: 'border-primary',\n  positive: 'border-viz-positive',\n  danger: 'border-destructive',\n} as const;\n\n/**\n * Tone → its three carriers.\n *\n * `spoken` is the one that is easiest to skip and the one that matters most:\n * `--viz-negative` is invisible in a greyscale print, in a screenshot pasted\n * into a chat, and to a screen reader. `neutral` earns no sentence because it\n * makes no claim — it is the tone for a number deliberately NOT being judged,\n * which is a different thing from `default`, a number nobody HAS judged.\n *\n * The rail is reused rather than a second colour: `danger` already exists for\n * the error state, and a cell cannot be both — a failed cell has no value to\n * have an opinion about.\n */\nconst TONE: Record<\n  StatTone,\n  { value: string; rail: keyof typeof RAIL_CLASS; spoken: string }\n> = {\n  default: { value: '', rail: 'idle', spoken: '' },\n  positive: { value: 'text-viz-positive', rail: 'positive', spoken: 'Good.' },\n  negative: {\n    value: 'text-viz-negative',\n    rail: 'danger',\n    spoken: 'Needs attention.',\n  },\n  neutral: { value: 'text-viz-neutral', rail: 'idle', spoken: '' },\n};\n\n/**\n * Mobile-first track counts. Written out statically because Tailwind scans\n * source as raw text and cannot see a runtime-built `lg:grid-cols-${n}`.\n *\n * Two columns at the 320 floor in every case: the desktop count is a ceiling,\n * not a promise. A six-track strip held at six on a phone gives each metric\n * ~45px, which clips the value it exists to show.\n */\nconst STRIP_COLS = {\n  2: 'grid-cols-2',\n  3: 'grid-cols-2 sm:grid-cols-3',\n  4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',\n  5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',\n  6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',\n} as const;\n\nexport type StatStripCols = keyof typeof STRIP_COLS;\n\nexport interface StatStripProps extends Omit<\n  React.ComponentProps<'section'>,\n  'children'\n> {\n  items: readonly StatItem[];\n  /**\n   * Visible caption above the strip. Also the strip's accessible name — a\n   * `<dl>` of eight numbers with no name is eight numbers from nowhere.\n   */\n  caption?: React.ReactNode;\n  /** Desktop track count. Collapses to 2 at the 320 floor regardless. */\n  cols?: StatStripCols;\n  /**\n   * Strip-wide absence. `partial` here is the `partialCoverage: true` case\n   * from the corpus: it qualifies EVERY number below as a floor, so it is\n   * announced once at the top rather than repeated on each cell.\n   */\n  state?: DataStateFlags;\n  announce?: AnnouncementOptions;\n  loading?: boolean;\n}\n\nexport const StatStrip = React.forwardRef<HTMLElement, StatStripProps>(\n  function StatStrip(\n    {\n      items,\n      caption,\n      cols = 4,\n      state,\n      announce,\n      loading = false,\n      className,\n      ...props\n    },\n    ref,\n  ) {\n    const strip = resolveDataState({ ...state, loading }, announce);\n\n    if (strip.state === 'loading') {\n      return (\n        <Skeleton\n          variant=\"stat-strip\"\n          data-slot=\"stat-strip\"\n          data-min-viewport={String(MIN_VIEWPORT)}\n          className={className}\n        />\n      );\n    }\n\n    return (\n      <section\n        ref={ref}\n        data-slot=\"stat-strip\"\n        data-state={strip.state}\n        data-qualifiers={strip.qualifiers.join(' ') || undefined}\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={cn('w-full', className)}\n        {...props}\n      >\n        {caption || strip.state !== 'idle' ? (\n          <div className=\"mb-2 flex flex-wrap items-center gap-2\">\n            {caption ? (\n              <p\n                data-slot=\"stat-strip-caption\"\n                className=\"font-mono text-ui-sm uppercase tracking-wide text-muted-foreground\"\n              >\n                {caption}\n              </p>\n            ) : null}\n            {/* Every active state, not just the winner. A strip that is both\n                partially covered and truncated is wrong twice and says so. */}\n            {strip.active\n              .filter((name) => name !== 'idle')\n              .map((name) => (\n                <DataStateBadge key={name} state={name} announce={announce} />\n              ))}\n          </div>\n        ) : null}\n\n        <dl className={cn('grid gap-x-4 gap-y-5', STRIP_COLS[cols])}>\n          {items.map((item) => (\n            <StatStripItem key={item.key} item={item} />\n          ))}\n        </dl>\n      </section>\n    );\n  },\n);\n\n/**\n * One cell.\n *\n * Split out because the three-state null is a branch that deserves to be read\n * on its own, not buried in a `.map()` inside the strip's layout.\n */\nfunction StatStripItem({ item }: { item: StatItem }) {\n  const { label, value, unit, note, prior, delta, tone = 'default', state, announce } = item;\n\n  const resolved = resolveDataState(state, announce);\n  const judgement = TONE[tone];\n  // Absence outranks judgement. A cell that failed to load, or was never\n  // counted, has no value for a tone to have an opinion about — painting it\n  // \"good\" would be an opinion about a number nobody has.\n  const emphasis =\n    resolved.state === 'idle'\n      ? judgement.rail\n      : resolved.state === 'error'\n        ? 'danger'\n        : resolved.state === 'not-applicable'\n          ? 'recede'\n          : resolved.state === 'first-measurement'\n            ? 'accent'\n            : 'muted';\n\n  // The measurement is missing. Show WHICH absence, never a 0 and never a\n  // bare dash — \"—\" is the one glyph that reads as both \"nothing\" and\n  // \"unknown\", which is precisely the ambiguity this component removes.\n  const unmeasured = value === null || value === undefined;\n\n  // A prior of exactly `null` is the \"no prior reading\" signal. `undefined`\n  // means the caller is not making a claim about change at all, which is a\n  // different thing and draws nothing.\n  const noPrior = prior === null;\n\n  return (\n    <div\n      data-slot=\"stat-strip-item\"\n      data-key={item.key}\n      data-state={resolved.state}\n      data-tone={tone === 'default' ? undefined : tone}\n      // The ONLY element allowed between `<dl>` and its `<dt>`/`<dd>` pair.\n      // The rail is this element's inline-start border for exactly that\n      // reason — see RAIL_CLASS.\n      className={cn(\n        'flex min-w-0 flex-col gap-1 border-s-2 ps-2',\n        RAIL_CLASS[emphasis],\n      )}\n    >\n      <dt className=\"font-mono text-ui-sm uppercase leading-tight tracking-wide text-muted-foreground\">\n        {label}\n      </dt>\n      <dd className=\"flex min-w-0 flex-col gap-1\">\n        {unmeasured ? (\n          <DataStateBadge\n            state={resolved.state === 'idle' ? 'empty' : resolved.state}\n            announce={announce}\n          />\n        ) : (\n          <>\n            {/* `dir=\"auto\"`: inside an RTL page the bidi algorithm reorders\n                  \"812 ms\" to \"ms 812\", because the digits are a neutral run in\n                  an RTL paragraph. `auto` resolves from the first strong\n                  character, so the measurement keeps its own reading order. */}\n            <span\n              dir=\"auto\"\n              className={cn(\n                'font-body text-h4 font-semibold leading-none tabular-nums break-words',\n                judgement.value,\n              )}\n            >\n              {typeof value === 'number' ? value.toLocaleString() : value}\n              {unit ? (\n                <span className=\"ml-0.5 text-ui font-normal text-muted-foreground\">\n                  {unit}\n                </span>\n              ) : null}\n              {/* The third carrier. A tone that lives only in a hue is a\n                    judgement a screen reader never hears and a greyscale print\n                    never shows — the same argument `Delta` makes about its\n                    direction glyph. */}\n              {judgement.spoken ? (\n                <span className=\"sr-only\"> {judgement.spoken}</span>\n              ) : null}\n            </span>\n            {/* A measured value can still be qualified — a count that is a\n                  floor is not the same number as a count that is a total. */}\n            {resolved.state !== 'idle' ? (\n              <DataStateBadge state={resolved.state} announce={announce} />\n            ) : null}\n          </>\n        )}\n\n        {/* The whole point. A missing prior is announced as a missing\n              prior — the caller's delta node is not rendered at all, so\n              there is no path by which it can print +0%. */}\n        {noPrior ? (\n          <DataStateBadge state=\"first-measurement\" announce={announce} />\n        ) : (\n          (delta ?? null)\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        {/* Qualifiers that did not win the cell still have to be heard.\n              A hatch a screen reader cannot perceive keeps the distinction\n              for sighted readers and destroys it for everyone else. */}\n        {resolved.qualifiers.length > 0 ? (\n          <span className=\"sr-only\">\n            {resolved.qualifiers\n              .map((name) => announceDataState(name, announce))\n              .join(' ')}\n          </span>\n        ) : null}\n      </dd>\n    </div>\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"
    }
  ],
  "meta": {
    "tier": "primitive",
    "client": false,
    "minViewport": 320,
    "loading": true,
    "version": "1.0.0",
    "since": "1.1.0"
  },
  "docs": "## @interlace/stat-strip\n\nInstalled to `components/ui/stat-strip.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/stat-strip';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/stat-strip\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
