{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-state-model",
  "type": "registry:ui",
  "title": "Data State Model",
  "description": "Nothing in this file imports React, touches the DOM, or renders anything. It is the part of the absence contract that can be *proved* — the union, the precedence order, the announcement each state owes a screen reader, and the class set that paints it — so…",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "feedback",
    "primitive"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json"
  ],
  "files": [
    {
      "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\n// @interlace/data-state-model v1.0.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/data-state-model\n// What changed since: https://ds.interlace.tools/c/data-state-model#history\n// Generated banner — keep it, the upgrade diff reads this version.\n"
    }
  ],
  "meta": {
    "tier": "primitive",
    "client": false,
    "minViewport": null,
    "loading": true,
    "version": "1.0.0",
    "since": "1.1.0"
  },
  "docs": "## @interlace/data-state-model\n\nInstalled to `components/ui/data-state-model.ts`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/data-state-model';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/data-state-model\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
