{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-contrast",
  "title": "Color Contrast",
  "description": "Says whether a foreground and background pair is readable, as a WCAG 2 contrast ratio with a pass or fail against each requirement the pair is held to. Reach for it wherever a colour is chosen and somebody then has to live with reading it: a design-system or design-token table that lists every pair in use, a theme or appearance editor, the brand and accent colour fields in an admin panel or tenant settings, checking the output of a generated or imported palette, a warning next to a colour input before the value is saved, an accessibility audit or review screen, chart series and status colours, dark-mode review, and a Storybook or docs page that has to show the figure rather than claim it. Common asks it answers: \"contrast checker react\", \"color contrast component\", \"wcag contrast ratio\", \"contrast ratio calculator\", \"check contrast of two colors\", \"is this text readable on this background\", \"4.5:1\", \"3:1\", \"7:1\", \"AA vs AAA contrast\", \"accessible color combination\", \"accessible text color on background\", \"relative luminance javascript\", \"wcag 2 contrast formula\", \"luminance contrast js\", \"shadcn contrast checker\", \"tailwind color contrast check\", \"design token contrast audit\", \"contrast ratio for oklch\", \"contrast of a css variable\", \"check contrast of theme tokens\", \"dark mode contrast check\", \"large text contrast threshold\", \"non-text contrast 1.4.11\", \"focus ring contrast\", \"icon button contrast\", \"contrast of semi-transparent text\", \"overlay text contrast\", \"why does my contrast checker say rgb(0,0,0)\", \"getComputedStyle color returns oklch\", \"コントラスト比 計算\", \"色のコントラスト チェック\", \"WCAG コントラスト react\", \"文字が読めるか 判定\", \"アクセシビリティ 配色 チェック\", \"背景色と文字色 比率\". **It resolves the colour the browser actually paints, which is why it works on a current theme at all.** The usual implementation reads `getComputedStyle(el).color` and pulls three integers out with a regex, and that assumes the computed value is `rgb(...)`. It is not: measured in Chrome 152, `oklch(0.7 0.15 250)` computes to `oklch(0.7 0.15 250)`, `lab(50% 40 -30)` to `lab(50 40 -30)`, `color(display-p3 1 0 0)` unchanged, and `color-mix(in srgb, red 30%, blue)` to `color(srgb 0.3 0 0.7)`. That is not a corner case any more — reading the eight principal theme tokens off shadcn/ui's own documentation site today, every one of them computes to `lab(...)`, and its main stylesheet holds 431 occurrences of `lab(` against one of `rgb(`. So the regex version reports nothing on the exact palettes people most want to check. This paints the value onto a 1×1 canvas and reads the pixel instead, which makes the browser do the conversion and handles every notation it ships, named colours and out-of-gamut values included. **It resolves the colour where you asked about it, not wherever a probe happened to sit.** `var(--primary)` and `currentColor` only mean something at a position in the tree, and a probe parked at the end of `body` does not return an error for them — it returns the inherited text colour. Measured: the same `var(--token)` string resolved to `oklch(0.55 0.2 25)` inside the subtree that declares it and to `rgb(26, 26, 26)` on a body-level probe, with nothing marking the second as unresolved. A detached element is worse still, answering the empty string for all thirteen notations tried, plain hex among them. Validity is settled separately with `CSS.supports`, because `color: notacolor` also computes to the inherited colour and is otherwise indistinguishable from a correct answer. **It re-measures when the theme changes, which is neither a prop change nor a resize.** If the inputs are tokens, both strings are identical before and after a dark-mode toggle while the answer moves: measured, adding the `dark` class to `<html>` took `--foreground` from 0/0/0 to 250/250/250 and `--background` from 255/255/255 to 10/10/10, turning a 21.00:1 pair into an 18.97:1 one with nothing for React to hear. A `MutationObserver` watches the document element's `class`, `style` and `data-theme`, and `prefers-color-scheme` is watched for a page themed by media query alone. A contrast figure that has gone stale is worse than none, because it is the number the reader is trusting. **A translucent colour has no ratio on its own, and this says so instead of assuming white.** `rgba`, `oklch(... / 0.6)` and `color-mix(..., transparent)` are detected by painting over white and over black and comparing, with no notation parsed; when one of them has no opaque colour behind it the status is `indeterminate` rather than a confident number. When there is something behind it, the real ancestor stack is composited in order rather than skipped — a 60%-black panel over a 251/251/250 page is 100/100/100, which is neither of them. Text is composited over the background and the background over the backdrop, in that order, because text sits on the background: collapsing the two reports a ratio against a colour that is nowhere on screen. Alpha is quantised to eight bits first, which is what the compositor does — against a canvas over twelve pairs the float form missed five and the quantised form matched all twelve. **Large text takes the weight as well as the size.** The rule is 18pt at any weight or 14pt when bold, so 18.67px is large at weight 700 and normal-size at weight 400, and a checker handed only a size cannot tell which — the same pair needs 3:1 in one case and 4.5:1 in the other. Sizes are accepted as CSS lengths, so `14pt` and `1.5rem` both work; the relative ones are measured against the document rather than guessed against a 16px root. An unknown size is held to the normal-text requirement rather than excused from one. 1.4.11 Non-text Contrast is reported as 3:1 at AA and AAA both, because it has no AAA counterpart and inventing a stricter one would fail pairs that conform. WCAG 2 only, deliberately. APCA — the perceptual model drafted for WCAG 3, and the fair objection to 2.x's ratio — is not computed here: it is not a Recommendation, its coefficients are versioned, and a number that disagrees with the reference implementation is worse than no number on a screen whose whole job is to be trusted. The figures are rounded the way the published checkers round them so they can be cross-checked, except in the thousandth-wide band where rounding up would read as meeting a requirement the verdict denies. Nothing is said in colour alone, which would be a poor joke here: every verdict is a word, the icons are `aria-hidden`, and the headline is a live region so a reader dragging a colour slider hears the new ratio. `contrastRatio`, `relativeLuminance`, `compositeOver`, `isLargeText`, `requiredRatio`, `gradeContrast`, `measureColorIn`, `findBackdrop` and `measureContrast` are all exported, the last of them synchronous and hook-free so a token table can grade dozens of pairs in one pass instead of one hook per row. Official shadcn/ui has nothing here, and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks — and searching all 223,289 bytes of it, the strings contrastRatio, relativeLuminance, luminance, WCAG, APCA, sRGB, getImageData and CSS.supports are every one of them a zero hit. Official never computes a luminance and never reads a pixel. Within pulld it sits beside color-picker, which chooses a colour, and theme-toggle, which switches between sets of them — neither asks whether the result can be read — and it is the only component here that resolves a CSS colour of any notation to the bytes a screen will actually paint.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/color-contrast.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* -------------------------------------------------------------------------------------------- *\n * The arithmetic — no DOM, so it can be tested without a browser.\n * -------------------------------------------------------------------------------------------- */\n\n/**\n * A colour in sRGB, one byte per channel.\n *\n * Every threshold WCAG 2 states is defined over sRGB, so this is the space the answer has to be\n * computed in no matter what notation the colour arrived in.\n */\nexport interface Srgb {\n  r: number\n  g: number\n  b: number\n}\n\n/** An sRGB colour that is not necessarily opaque. `a` runs 0–1. */\nexport interface SrgbA extends Srgb {\n  a: number\n}\n\nconst clampByte = (n: number) => (Number.isFinite(n) ? Math.min(255, Math.max(0, n)) : 0)\n\n/**\n * The luminance of one channel, with the sRGB transfer function undone.\n *\n * The constants are WCAG's own: a linear segment below 0.04045 and a 2.4 power curve above it.\n */\nfunction toLinear(channel: number): number {\n  const s = clampByte(channel) / 255\n  return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)\n}\n\n/**\n * Relative luminance, 0 for black and 1 for white.\n *\n * **This is not the average of the channels, and substituting the average is not a small error.**\n * The channels are first taken back to light-linear values and then weighted 0.2126 / 0.7152 /\n * 0.0722, because the eye is roughly ten times more sensitive to green than to blue. Scored against\n * the mean of the three channels over the same pairs: `#777777` on white is **4.48:1** and the mean\n * says **2.03:1** — a pair that passes AA for body text, reported as failing it. `#0000ff` on white\n * is **8.59:1** and the mean says **2.74:1**; `#ffff00` on black is **19.56:1** against **14.33:1**.\n * The mean is wrong in both directions and wrong by enough to flip the verdict.\n */\nexport function relativeLuminance({ r, g, b }: Srgb): number {\n  return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)\n}\n\n/**\n * The WCAG 2 contrast ratio of two opaque colours, from 1 to 21, order-independent.\n *\n * **The 0.05 in both terms is load-bearing and is the first thing a hand-rolled version drops.**\n * It is a flare term standing in for ambient light reflecting off the screen, and without it the\n * denominator is zero for every pair involving black: measured, `brighter / darker` returns\n * `Infinity` for white on black and for black on black alike, so the failure is not a slightly\n * wrong number but a ratio that cannot be compared to a threshold or printed.\n *\n * Verified against the figures the published contrast checkers report, to the digit: white on black\n * **21.00**, `#777777` on white **4.48**, `#0000ff` on white **8.59**, `#ff0000` on white **4.00**,\n * `#595959` on white **7.00**, `#767676` on white **4.54**, and white on white **1.00**.\n */\nexport function contrastRatio(a: Srgb, b: Srgb): number {\n  const la = relativeLuminance(a)\n  const lb = relativeLuminance(b)\n  const lighter = Math.max(la, lb)\n  const darker = Math.min(la, lb)\n  return (lighter + 0.05) / (darker + 0.05)\n}\n\n/**\n * `top` painted over `bottom`, which is the only way a translucent colour acquires a luminance.\n *\n * **Alpha is quantised to eight bits first, because that is what the compositor actually uses.**\n * Carrying the float straight into `a * top + (1 - a) * bottom` disagrees with what the browser\n * paints: over the twelve pairs measured against a canvas — six colours, each over white and over\n * black — the float form missed **five**, every miss by exactly one in a channel and every one of\n * them over the white base, while rounding `a` to `round(a * 255) / 255` first matched **twelve out\n * of twelve**. `rgba(255, 0, 0, 0.5)` over white is 255/127/127 and not 255/128/128, because the\n * alpha in play is 128/255 rather than 0.5. One count in a channel will not often move a verdict,\n * but it moves it for free on a pair sitting on a threshold, and there is no reason to be wrong.\n */\nexport function compositeOver(top: SrgbA, bottom: Srgb): Srgb {\n  const a = Math.round(Math.min(1, Math.max(0, Number.isFinite(top.a) ? top.a : 1)) * 255) / 255\n  const mix = (t: number, b: number) => Math.round(a * clampByte(t) + (1 - a) * clampByte(b))\n  return { r: mix(top.r, bottom.r), g: mix(top.g, bottom.g), b: mix(top.b, bottom.b) }\n}\n\n/* -------------------------------------------------------------------------------------------- *\n * Thresholds\n * -------------------------------------------------------------------------------------------- */\n\n/** Which conformance level the headline verdict is held to. */\nexport type ContrastLevel = \"AA\" | \"AAA\"\n\n/**\n * What the pair is for.\n *\n * `\"text\"` is 1.4.3 / 1.4.6 Contrast (Minimum / Enhanced); `\"non-text\"` is 1.4.11 Non-text Contrast,\n * which covers the visual boundary of a control, a focus ring, and any graphic a reader has to make\n * out — an icon-only button, a chart series, the bar of a progress meter.\n */\nexport type ContrastUsage = \"text\" | \"non-text\"\n\n/** The absolute CSS length units, in px. The rest need a DOM to resolve. */\nconst ABSOLUTE_UNITS: Record<string, number> = {\n  px: 1,\n  pt: 96 / 72,\n  pc: 16,\n  in: 96,\n  cm: 96 / 2.54,\n  mm: 96 / 25.4,\n  q: 96 / 101.6,\n}\n\n/** 18pt, the size at which text is \"large\" at any weight. */\nexport const LARGE_TEXT_PX = 24\n\n/** 14pt, the size at which bold text is \"large\". */\nexport const LARGE_BOLD_TEXT_PX = 14 * ABSOLUTE_UNITS.pt\n\n/**\n * Slack on the large-text comparison, in px.\n *\n * **Without it the threshold is decided by floating-point association.** `(14 * 96) / 72` is\n * 18.666666666666668 and `14 * (96 / 72)` — which is what a `pt` length converted through a unit\n * table comes to — is 18.666666666666664, so a strict `>=` against the first form rejects the\n * second: a caller writing `fontSize=\"14pt\"` with `fontWeight={700}` is told the text is not large\n * and silently held to 4.5:1 instead of the 3:1 it is entitled to. The browser rounds its own\n * computed value to four decimals (`14pt` reads back as `18.6667px`), which is a second source of\n * the same kind of miss. A hundredth of a pixel is far below any size difference a reader could\n * perceive and well above either artefact.\n */\nexport const LARGE_TEXT_EPSILON = 0.01\n\n/** The weight at which CSS calls text bold. `bold` and `bolder` both compute to this. */\nexport const BOLD_WEIGHT = 700\n\n/** The size and weight of the text a pair is being judged for. */\nexport interface TextStyle {\n  /** Used font size, in px. */\n  px: number\n  /** Used font weight as a number — `bold` computes to 700. */\n  weight: number\n}\n\n/**\n * Whether WCAG counts this text as large, which drops the requirement from 4.5:1 to 3:1.\n *\n * **It takes the size *and* the weight, and a component handed only a size cannot answer it.** The\n * rule is 18pt at any weight or 14pt when bold, and 14pt bold is a smaller number of pixels than\n * 18pt regular — so at 20px the answer is \"large\" at weight 700 and \"not large\" at weight 400, and\n * a caller who passes the size alone has told you nothing about which. Verified against the browser\n * rather than converted by hand: a probe set to `18pt` computes to `24px`, `14pt` to `18.6667px`\n * and `1pt` to `1.33333px`, and `font-weight: bold` and `bolder` both compute to `700` while\n * `lighter` computes to `100`. This defaults the weight to 400 when it is not given, which is the\n * conservative direction — it asks for more contrast, not less.\n */\nexport function isLargeText({ px, weight }: TextStyle): boolean {\n  if (!Number.isFinite(px)) return false\n  const w = Number.isFinite(weight) ? weight : 400\n  return (\n    px >= LARGE_TEXT_PX - LARGE_TEXT_EPSILON ||\n    (px >= LARGE_BOLD_TEXT_PX - LARGE_TEXT_EPSILON && w >= BOLD_WEIGHT)\n  )\n}\n\n/** One requirement, and whether the measured ratio meets it. */\nexport interface ContrastCheck {\n  id: \"body-aa\" | \"body-aaa\" | \"large-aa\" | \"large-aaa\" | \"non-text\"\n  /** Human-readable name of the requirement, e.g. \"Body text (AA)\". */\n  label: string\n  /** The criterion it comes from, e.g. \"1.4.3\". */\n  criterion: string\n  /** The ratio the criterion asks for. */\n  required: number\n  passes: boolean\n}\n\n/** Everything known about a measured pair. */\nexport interface ContrastReport {\n  /** The ratio itself, unrounded. */\n  ratio: number\n  /** Whether the text was judged large. Always false for `\"non-text\"`. */\n  large: boolean\n  /** The one requirement that applies to the pair as described — what the headline reports. */\n  operative: ContrastCheck\n  /** Every requirement WCAG 2 states for this usage, in order, for showing the detail. */\n  checks: ContrastCheck[]\n}\n\n/**\n * The ratio WCAG 2 asks for.\n *\n * **1.4.11 is a AA criterion with no AAA counterpart**, so asking for AAA on a non-text pair gets\n * 3:1 back rather than a stricter number invented here. Reporting a higher bar than the standard\n * states would fail pairs that conform.\n */\nexport function requiredRatio(usage: ContrastUsage, level: ContrastLevel, large: boolean): number {\n  if (usage === \"non-text\") return 3\n  if (level === \"AAA\") return large ? 4.5 : 7\n  return large ? 3 : 4.5\n}\n\n/** Grades a ratio against every requirement for this usage. */\nexport function gradeContrast(\n  ratio: number,\n  usage: ContrastUsage,\n  level: ContrastLevel,\n  text?: TextStyle,\n): ContrastReport {\n  const large = usage === \"text\" && text !== undefined && isLargeText(text)\n  const check = (\n    id: ContrastCheck[\"id\"],\n    label: string,\n    criterion: string,\n    required: number,\n  ): ContrastCheck => ({ id, label, criterion, required, passes: ratio >= required })\n\n  if (usage === \"non-text\") {\n    const only = check(\"non-text\", \"UI component or graphic (AA)\", \"1.4.11\", requiredRatio(\"non-text\", \"AA\", false))\n    return { ratio, large: false, operative: only, checks: [only] }\n  }\n\n  // Every number here comes from `requiredRatio` rather than being written out again: the\n  // thresholds are the one thing in this file that must not be able to disagree with itself.\n  const checks = [\n    check(\"body-aa\", \"Body text (AA)\", \"1.4.3\", requiredRatio(\"text\", \"AA\", false)),\n    check(\"body-aaa\", \"Body text (AAA)\", \"1.4.6\", requiredRatio(\"text\", \"AAA\", false)),\n    check(\"large-aa\", \"Large text (AA)\", \"1.4.3\", requiredRatio(\"text\", \"AA\", true)),\n    check(\"large-aaa\", \"Large text (AAA)\", \"1.4.6\", requiredRatio(\"text\", \"AAA\", true)),\n  ]\n  const wanted: ContrastCheck[\"id\"] = large\n    ? level === \"AAA\"\n      ? \"large-aaa\"\n      : \"large-aa\"\n    : level === \"AAA\"\n      ? \"body-aaa\"\n      : \"body-aa\"\n  const operative = checks.find((c) => c.id === wanted) ?? checks[0]\n  return { ratio, large, operative, checks }\n}\n\n/**\n * The ratio as it should be shown, to two decimals.\n *\n * **Rounded to nearest, because the figure has to be comparable to the checker the reader is\n * cross-checking against.** Every published contrast checker rounds, so `#777777` on white is\n * quoted everywhere as 4.48:1 — its exact value is 4.4767. Truncating instead prints 4.47 and is\n * then a hundredth out against every other tool for roughly half of all pairs, which is read as\n * this component being wrong rather than as a difference in rounding.\n *\n * **Given the requirement, it truncates in the one band where rounding would contradict the\n * verdict.** A pair at 4.4993 rounds to \"4.50:1\", and printing that immediately above \"Fails Body\n * text (AA), needs 4.5:1\" is the one case where the honest figure is the shorter one. That band is\n * a thousandth wide, so it costs nothing anywhere else. The comparison itself is always on the\n * unrounded ratio.\n */\nexport function formatRatio(ratio: number, required?: number): string {\n  if (!Number.isFinite(ratio)) return \"—\"\n  const rounded = Math.round(ratio * 100) / 100\n  if (required !== undefined && ratio < required && rounded >= required) {\n    return (Math.floor(ratio * 100) / 100).toFixed(2)\n  }\n  return rounded.toFixed(2)\n}\n\n/**\n * A CSS length in px, or null when the unit is relative to something only the document knows.\n *\n * `em`, `rem`, `%`, `ch`, `ex` and the viewport units all come back null so the caller measures\n * them against the real element instead of guessing a 16px root.\n */\nexport function cssLengthToPx(value: string | number | undefined | null): number | null {\n  if (value === undefined || value === null) return null\n  if (typeof value === \"number\") return Number.isFinite(value) ? value : null\n  const match = /^\\s*([+-]?(?:\\d+\\.?\\d*|\\.\\d+))\\s*([a-z%]*)\\s*$/i.exec(value)\n  if (!match) return null\n  const n = Number(match[1])\n  if (!Number.isFinite(n)) return null\n  const unit = match[2].toLowerCase()\n  if (!unit) return n\n  const factor = ABSOLUTE_UNITS[unit]\n  return factor === undefined ? null : n * factor\n}\n\n/** A CSS font-weight as the number CSS computes it to, or null when it needs the document. */\nexport function cssWeightToNumber(value: string | number | undefined | null): number | null {\n  if (value === undefined || value === null) return null\n  if (typeof value === \"number\") return Number.isFinite(value) ? value : null\n  const keyword = value.trim().toLowerCase()\n  if (keyword === \"normal\") return 400\n  if (keyword === \"bold\") return 700\n  const n = Number(keyword)\n  return Number.isFinite(n) ? n : null\n}\n\n/* -------------------------------------------------------------------------------------------- *\n * Resolving a CSS colour, which needs a laid-out document and a canvas\n * -------------------------------------------------------------------------------------------- */\n\n/** Why a colour could not be resolved. */\nexport type ColorFailure =\n  /** Not a colour CSS accepts — a typo, or a bare word that is not a named colour. */\n  | \"invalid\"\n  /** No document to resolve against: server render, or before the first effect. */\n  | \"no-dom\"\n\n/** A resolved colour, composited over whatever backdrop it was measured against. */\nexport type ColorMeasurement =\n  | {\n      ok: true\n      /** The colour as painted, opaque, in sRGB bytes. */\n      color: Srgb\n      /** True when the input carried any transparency, so `color` depends on the backdrop. */\n      translucent: boolean\n      /** The computed value the browser resolved the input to, for showing the reader. */\n      computed: string\n    }\n  | { ok: false; reason: ColorFailure }\n\nconst WHITE: Srgb = { r: 255, g: 255, b: 255 }\nconst BLACK: Srgb = { r: 0, g: 0, b: 0 }\n\nconst toCss = ({ r, g, b }: Srgb) => `rgb(${clampByte(r)} ${clampByte(g)} ${clampByte(b)})`\n\nlet cachedContext: CanvasRenderingContext2D | null | undefined\n\n/** One 1×1 canvas for the life of the module — this is called several times per measurement. */\nfunction context2d(): CanvasRenderingContext2D | null {\n  if (cachedContext !== undefined) return cachedContext\n  if (typeof document === \"undefined\") return (cachedContext = null)\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = 1\n  canvas.height = 1\n  cachedContext = canvas.getContext(\"2d\", { willReadFrequently: true })\n  return cachedContext\n}\n\n/**\n * The sRGB bytes of `layers` painted bottom-first over `base`.\n *\n * **This is the step that converts the colour, and there is no arithmetic here that could do it\n * instead.** A computed colour is not `rgb()` in general: measured in Chrome 152, `oklch(0.7 0.15\n * 250)` computes to `oklch(0.7 0.15 250)`, `lab(50% 40 -30)` to `lab(50 40 -30)`,\n * `color(display-p3 1 0 0)` unchanged, and `color-mix(in srgb, red 30%, blue)` to\n * `color(srgb 0.3 0 0.7)` — so the regex that pulls three integers out of `getComputedStyle` finds\n * nothing. That is not a corner case any more: reading the eight principal theme tokens off\n * shadcn/ui's own documentation site today, **every one of them computes to `lab(...)`**, and its\n * main stylesheet contains 431 occurrences of `lab(` against one of `rgb(`. Painting the value and\n * reading the pixel asks the browser to do the conversion, which means every notation it will ever\n * ship is already handled, and out-of-gamut colours are reported as the screen will actually show\n * them (`color(display-p3 1 0 0)` reads back 255/0/0).\n *\n * **Painting over an opaque base, rather than reading the colour's own channels, is also deliberate.**\n * `getImageData` un-premultiplies, and at low alpha that is lossy: `rgba(200, 100, 50, 0.01)` reads\n * back as 170/85/85, and even at 0.05 the blue channel comes back 59 instead of 50. Composited onto\n * an opaque base the pixel is opaque, nothing is un-premultiplied, and the result is the browser's\n * own compositing rather than a reimplementation of it.\n */\nfunction paintStack(base: Srgb, layers: string[]): Srgb | null {\n  const ctx = context2d()\n  if (!ctx) return null\n  ctx.clearRect(0, 0, 1, 1)\n  ctx.fillStyle = toCss(base)\n  ctx.fillRect(0, 0, 1, 1)\n  for (const layer of layers) {\n    ctx.fillStyle = layer\n    ctx.fillRect(0, 0, 1, 1)\n  }\n  const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data\n  return { r, g, b }\n}\n\nconst same = (a: Srgb | null, b: Srgb | null) =>\n  a !== null && b !== null && a.r === b.r && a.g === b.g && a.b === b.b\n\n/**\n * Asks the browser what `value` means for `property` at `context`'s position in the document.\n *\n * **The probe has to be inside the document, and inside the right part of it.** A detached element\n * answers nothing at all — `getComputedStyle` on one returned the empty string for all thirteen\n * colour notations tried, including plain hex. Worse, a probe that is in the document but in the\n * wrong place answers *wrongly rather than not at all*: `var(--token)` measured on a probe inside\n * the subtree that declares the token resolved to `oklch(0.55 0.2 25)`, and the same string on a\n * probe appended to `body` resolved to `rgb(26, 26, 26)` — the inherited text colour, because an\n * undefined custom property makes the declaration invalid at computed-value time and the property\n * falls back to its inherited value. `currentColor` behaves the same way. Both are plausible\n * colours, neither is flagged, and the ratio computed from them is simply wrong, which is why the\n * context element is required rather than optional.\n *\n * A subtree that is `display: none` is still fine to measure, unlike measuring a size: inside one,\n * `var(--local)` resolved to its `oklch(...)` value and painted correctly. Colour is a computed\n * value and does not wait for layout.\n */\nfunction probeComputed(context: Element, property: \"color\" | \"fontSize\" | \"fontWeight\", value: string): string {\n  const probe = document.createElement(\"span\")\n  probe.style.cssText =\n    \"position:absolute;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none\"\n  probe.style[property] = value\n  context.appendChild(probe)\n  const computed = getComputedStyle(probe)[property]\n  probe.remove()\n  return computed\n}\n\n/**\n * Resolves `value` as a colour in `context`'s position, composited over `over`.\n *\n * `CSS.supports` is the validity gate rather than a look at the computed value, because **an\n * invalid colour is indistinguishable from a valid one downstream**: setting `color: notacolor` on\n * a probe computed to `rgb(26, 26, 26)`, the inherited colour, exactly as a correct answer would.\n * `CSS.supports(\"color\", …)` is true for `currentColor`, for `var(--x, #0f0)` and for the\n * out-of-range-but-clamping `rgb(300 0 0)`, and false for `notacolor` and for the empty string,\n * which is the division wanted here.\n *\n * Transparency is detected by painting the value over white and over black and comparing: identical\n * means opaque, different means some of the backdrop came through. Measured, that separates\n * `rgba(255, 0, 0, 0.5)`, `oklch(0.7 0.15 250 / 0.6)`, `color-mix(in srgb, red 30%, transparent)`\n * and `transparent` itself from their opaque counterparts, without parsing a single notation. A\n * value the canvas were to refuse would read as fully transparent and so as translucent, which\n * sends it to the \"needs a backdrop\" answer rather than to a confident wrong number.\n */\nexport function measureColorIn(\n  context: Element | null | undefined,\n  value: string,\n  over: Srgb = WHITE,\n): ColorMeasurement {\n  if (typeof document === \"undefined\" || typeof window === \"undefined\") {\n    return { ok: false, reason: \"no-dom\" }\n  }\n  const input = (value ?? \"\").trim()\n  if (!input) return { ok: false, reason: \"invalid\" }\n  if (typeof CSS !== \"undefined\" && typeof CSS.supports === \"function\") {\n    if (!CSS.supports(\"color\", input)) return { ok: false, reason: \"invalid\" }\n  }\n  const host = context ?? document.body\n  if (!host || !host.isConnected) return { ok: false, reason: \"no-dom\" }\n\n  const computed = probeComputed(host, \"color\", input)\n  if (!computed) return { ok: false, reason: \"no-dom\" }\n\n  const onWhite = paintStack(WHITE, [computed])\n  const onBlack = paintStack(BLACK, [computed])\n  if (onWhite === null || onBlack === null) return { ok: false, reason: \"no-dom\" }\n  const translucent = !same(onWhite, onBlack)\n  const color = translucent ? paintStack(over, [computed]) : onWhite\n  if (color === null) return { ok: false, reason: \"no-dom\" }\n  return { ok: true, color, translucent, computed }\n}\n\n/** What is actually painted underneath an element. */\nexport interface Backdrop {\n  color: Srgb\n  /**\n   * True when no ancestor painted an opaque background and white was assumed — the colour of the\n   * browser's own canvas, which a user stylesheet or a forced-colours mode can change.\n   */\n  assumed: boolean\n}\n\n/**\n * The colour painted behind `from`, by walking up until something opaque is found.\n *\n * An element with no background of its own computes to `rgba(0, 0, 0, 0)`, so the nearest painted\n * ancestor is the one that matters — measured on a page, two nested transparent `div`s and then\n * `body` at `rgb(251, 251, 250)`. Translucent layers found on the way up are kept and composited in\n * order rather than skipped, because a 60%-opaque panel over a dark page is a different backdrop\n * from either of them.\n *\n * Backgrounds only. An image, a gradient or a `backdrop-filter` behind the text has no single\n * colour and so no single ratio; WCAG's own advice there is to test the lightest and darkest points\n * by hand, and this reports the background colour underneath rather than pretending to average an\n * image it cannot see.\n */\nexport function findBackdrop(from: Element | null | undefined): Backdrop {\n  if (!from || typeof window === \"undefined\") return { color: WHITE, assumed: true }\n  const layers: string[] = []\n  let assumed = true\n  for (let node: Element | null = from; node; node = node.parentElement) {\n    const bg = getComputedStyle(node).backgroundColor\n    if (!bg) continue\n    const onWhite = paintStack(WHITE, [bg])\n    const onBlack = paintStack(BLACK, [bg])\n    if (onWhite === null || onBlack === null) break\n    if (same(onWhite, WHITE) && same(onBlack, BLACK)) continue // fully transparent\n    layers.push(bg)\n    if (same(onWhite, onBlack)) {\n      assumed = false\n      break\n    }\n  }\n  const color = paintStack(WHITE, layers.reverse())\n  return { color: color ?? WHITE, assumed }\n}\n\n/* -------------------------------------------------------------------------------------------- *\n * The hook\n * -------------------------------------------------------------------------------------------- */\n\nexport interface UseContrastOptions {\n  /** The text or graphic colour, in any notation CSS accepts. */\n  foreground: string\n  /** The colour behind it. */\n  background: string\n  /**\n   * An opaque colour under `background`, needed when either colour is translucent.\n   *\n   * Left out, the colour painted behind the component is walked up and used, and if nothing opaque\n   * is found the browser canvas is assumed white.\n   */\n  backdrop?: string\n  usage?: ContrastUsage\n  level?: ContrastLevel\n  /** Size of the text being judged. A string is resolved against the document, so `18pt` works. */\n  fontSize?: number | string\n  fontWeight?: number | string\n}\n\n/** What `useContrast` settled on. */\nexport interface ContrastState {\n  /**\n   * `pending` before the first measurement (and on the server), `invalid` when a colour is not a\n   * colour, `indeterminate` when a translucent colour has no opaque backdrop to sit on, and\n   * `measured` otherwise.\n   */\n  status: \"pending\" | \"measured\" | \"invalid\" | \"indeterminate\"\n  report: ContrastReport | null\n  foreground: Srgb | null\n  background: Srgb | null\n  /** Which inputs CSS rejected, for pointing at the field that needs fixing. */\n  invalid: (\"foreground\" | \"background\" | \"backdrop\")[]\n  /** Which inputs carried transparency. */\n  translucent: { foreground: boolean; background: boolean }\n  /** True when the backdrop under a translucent colour was assumed rather than found. */\n  assumedBackdrop: boolean\n  /** The computed values the browser resolved the inputs to. */\n  computed: { foreground: string | null; background: string | null }\n}\n\nconst PENDING: ContrastState = {\n  status: \"pending\",\n  report: null,\n  foreground: null,\n  background: null,\n  invalid: [],\n  translucent: { foreground: false, background: false },\n  assumedBackdrop: false,\n  computed: { foreground: null, background: null },\n}\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\n/**\n * Measures a pair at `context`'s position in the document and grades it, in one call.\n *\n * Exported separately from the hook because a token table grades dozens of pairs against the same\n * two or three backgrounds, and a hook per row buys nothing: this is a synchronous read with no\n * state of its own, so a caller can map it over a list inside a single effect. It is also the whole\n * of the decision — which colour is composited over which, and when the honest answer is \"no\n * ratio\" — kept out of the effect so it can be exercised without one.\n *\n * The order is forced and is the reason this is one function rather than three calls. The backdrop\n * has to be known before the background can be composited, and the background before the\n * foreground, because **text sits on the background and not on the backdrop**: translucent text\n * over a translucent panel over a page is three layers, and compositing the text straight onto the\n * page skips the panel and reports a ratio against a colour that is nowhere on screen.\n */\nexport function measureContrast(\n  context: Element,\n  {\n    foreground,\n    background,\n    backdrop,\n    usage = \"text\",\n    level = \"AA\",\n    fontSize,\n    fontWeight,\n  }: UseContrastOptions,\n): ContrastState {\n  const invalid: ContrastState[\"invalid\"] = []\n\n  // The backdrop first: the other two may need it to mean anything.\n  let base: Srgb\n  let assumedBackdrop = false\n  if (backdrop !== undefined) {\n    const measured = measureColorIn(context, backdrop, WHITE)\n    if (!measured.ok) {\n      invalid.push(\"backdrop\")\n      const found = findBackdrop(context)\n      base = found.color\n      assumedBackdrop = found.assumed\n    } else {\n      base = measured.color\n      assumedBackdrop = measured.translucent\n    }\n  } else {\n    const found = findBackdrop(context)\n    base = found.color\n    assumedBackdrop = found.assumed\n  }\n\n  const bg = measureColorIn(context, background, base)\n  // Text sits on the background, not on the backdrop, so a translucent foreground is composited\n  // over whatever the background resolved to.\n  const bgColor = bg.ok ? bg.color : base\n  const fg = measureColorIn(context, foreground, bgColor)\n\n  if (!fg.ok && fg.reason === \"invalid\") invalid.push(\"foreground\")\n  if (!bg.ok && bg.reason === \"invalid\") invalid.push(\"background\")\n\n  if (!fg.ok || !bg.ok) {\n    return {\n      ...PENDING,\n      status: invalid.length > 0 ? \"invalid\" : \"pending\",\n      invalid,\n      assumedBackdrop,\n    }\n  }\n\n  // A translucent colour with nothing known under it has no ratio. Saying so is the honest answer;\n  // quietly assuming white is how a dark theme gets told its overlay text passes.\n  const needsBackdrop = (fg.translucent || bg.translucent) && assumedBackdrop\n  const translucent = { foreground: fg.translucent, background: bg.translucent }\n  const computed = { foreground: fg.computed, background: bg.computed }\n\n  if (needsBackdrop) {\n    return {\n      status: \"indeterminate\",\n      report: null,\n      foreground: fg.color,\n      background: bg.color,\n      invalid,\n      translucent,\n      assumedBackdrop,\n      computed,\n    }\n  }\n\n  let text: TextStyle | undefined\n  if (usage === \"text\") {\n    const px =\n      cssLengthToPx(fontSize) ??\n      (fontSize === undefined\n        ? null\n        : cssLengthToPx(probeComputed(context, \"fontSize\", String(fontSize))))\n    const weight =\n      cssWeightToNumber(fontWeight) ??\n      (fontWeight === undefined\n        ? null\n        : cssWeightToNumber(probeComputed(context, \"fontWeight\", String(fontWeight))))\n    if (px !== null) text = { px, weight: weight ?? 400 }\n  }\n\n  return {\n    status: \"measured\",\n    report: gradeContrast(contrastRatio(fg.color, bg.color), usage, level, text),\n    foreground: fg.color,\n    background: bg.color,\n    invalid,\n    translucent,\n    assumedBackdrop,\n    computed,\n  }\n}\n\n/**\n * Measures a foreground/background pair at `contextRef`'s position and grades it.\n *\n * Exported on its own because the grade is worth having without this component's markup — a cell in\n * a token table, a warning beside a colour input, a row in an audit.\n *\n * **It re-measures when the theme changes, which is not a prop change and not a resize.** If the\n * colours are tokens, `foreground` and `background` can both be the same two strings before and\n * after a dark-mode toggle while the answer moves a long way: measured on shadcn/ui's own site,\n * adding the `dark` class to `<html>` took `--foreground` from 0/0/0 to 250/250/250 and\n * `--background` from 255/255/255 to 10/10/10, which is the difference between a 21.00:1 pair and an\n * 18.97:1 one. Nothing in React hears about that, so a `MutationObserver` watches the document\n * element's `class`, `style` and `data-theme` — the three places the theme switchers in circulation\n * write — and `prefers-color-scheme` is watched for a page themed by media query alone. A\n * contrast component that goes stale on a theme toggle is worse than no contrast component, because\n * the number it keeps showing is the one the reader is trusting.\n */\nexport function useContrast(\n  contextRef: React.RefObject<Element | null>,\n  {\n    foreground,\n    background,\n    backdrop,\n    usage = \"text\",\n    level = \"AA\",\n    fontSize,\n    fontWeight,\n  }: UseContrastOptions,\n): ContrastState {\n  const [state, setState] = React.useState<ContrastState>(PENDING)\n  // Bumped by anything that can change the answer without changing a prop.\n  const [revision, setRevision] = React.useState(0)\n\n  useIsomorphicLayoutEffect(() => {\n    const context = contextRef.current\n    if (!context || typeof window === \"undefined\") return\n    setState(\n      measureContrast(context, {\n        foreground,\n        background,\n        backdrop,\n        usage,\n        level,\n        fontSize,\n        fontWeight,\n      }),\n    )\n  }, [contextRef, foreground, background, backdrop, usage, level, fontSize, fontWeight, revision])\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\") return\n    const bump = () => setRevision((n) => n + 1)\n\n    const observer = new MutationObserver(bump)\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    })\n\n    const media = window.matchMedia?.(\"(prefers-color-scheme: dark)\")\n    media?.addEventListener?.(\"change\", bump)\n\n    // One re-measurement once the document has finished its initial style and font work. A font\n    // cannot change a colour, but a theme stylesheet that was not render-blocking can, and this is\n    // the cheapest already-available signal that the first paint has settled. It does not catch a\n    // stylesheet injected later than that — nothing short of polling would, and a theme that\n    // arrives after the page is interactive will come in through the attribute observer above or\n    // not at all.\n    let cancelled = false\n    document.fonts?.ready?.then(() => {\n      if (!cancelled) bump()\n    })\n\n    return () => {\n      cancelled = true\n      observer.disconnect()\n      media?.removeEventListener?.(\"change\", bump)\n    }\n  }, [])\n\n  return state\n}\n\n/* -------------------------------------------------------------------------------------------- *\n * The component\n * -------------------------------------------------------------------------------------------- */\n\nexport const colorContrastLabels = {\n  /** Shown while the first measurement has not happened — on the server, and in the first paint. */\n  pending: \"Measuring…\",\n  invalid: \"Not a colour\",\n  invalidHint: \"Enter a colour CSS accepts, such as #3b82f6, oklch(0.7 0.15 250) or var(--primary).\",\n  indeterminate: \"Needs a backdrop\",\n  indeterminateHint:\n    \"One of these colours is translucent, so its contrast depends on what is painted underneath. Give a backdrop colour to get a ratio.\",\n  passes: \"Passes\",\n  fails: \"Fails\",\n  ratioLabel: \"Contrast ratio\",\n  sample: \"The quick brown fox jumps over the lazy dog\",\n  largeText: \"counted as large text\",\n  normalText: \"counted as normal-size text\",\n  unknownSize: \"no text size given, judged as normal-size text\",\n  assumedBackdrop: \"backdrop assumed white\",\n}\n\nexport type ColorContrastLabels = typeof colorContrastLabels\n\nexport interface ColorContrastProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"color\">,\n    UseContrastOptions {\n  /** Text drawn in the pair, or `false` for no preview. */\n  sample?: React.ReactNode | false\n  /** Show the full list of requirements under the headline. Defaults to true. */\n  detail?: boolean\n  labels?: Partial<ColorContrastLabels>\n  /** Called with the state whenever the measurement settles. */\n  onReport?: (state: ContrastState) => void\n}\n\n/**\n * Says whether a foreground/background pair is readable, and why.\n *\n * Reports WCAG 2 ratios only. APCA — the perceptual model drafted for WCAG 3, which is the usual\n * objection to the numbers here, and a fair one, since 2.x's ratio is known to be harsh on light\n * text and generous on dark — is deliberately not computed: it is not a Recommendation, its\n * coefficients are versioned, and a number that disagrees with the reference implementation is\n * worse than no number on a screen whose whole purpose is to be trusted. Conformance, procurement\n * and the law are written against 2.x today, so that is what this reports, and it reports it exactly.\n *\n * **Nothing here is said in colour alone**, which would be a poor joke in this component: every\n * verdict is a word, the icons are `aria-hidden`, and the headline is a live region so a reader\n * dragging a colour slider hears the new ratio instead of watching for a tick to change shape.\n */\nexport const ColorContrast = React.forwardRef<HTMLDivElement, ColorContrastProps>(\n  function ColorContrast(\n    {\n      foreground,\n      background,\n      backdrop,\n      usage = \"text\",\n      level = \"AA\",\n      fontSize,\n      fontWeight,\n      sample,\n      detail = true,\n      labels: labelOverrides,\n      onReport,\n      className,\n      ...props\n    },\n    forwardedRef,\n  ) {\n    const labels = { ...colorContrastLabels, ...labelOverrides }\n    const root = React.useRef<HTMLDivElement | null>(null)\n    const setRef = React.useCallback(\n      (node: HTMLDivElement | null) => {\n        root.current = node\n        if (typeof forwardedRef === \"function\") forwardedRef(node)\n        else if (forwardedRef) forwardedRef.current = node\n      },\n      [forwardedRef],\n    )\n\n    const state = useContrast(root, {\n      foreground,\n      background,\n      backdrop,\n      usage,\n      level,\n      fontSize,\n      fontWeight,\n    })\n\n    // Guarded on the value last handed over rather than on a dependency array: a parent that\n    // re-renders in response to this callback must not turn a measurement into a loop.\n    const reported = React.useRef<ContrastState | null>(null)\n    React.useEffect(() => {\n      if (!onReport || reported.current === state) return\n      reported.current = state\n      onReport(state)\n    }, [onReport, state])\n\n    const { report } = state\n    const headline =\n      state.status === \"measured\" && report\n        ? `${formatRatio(report.ratio, report.operative.required)}:1`\n        : state.status === \"invalid\"\n          ? labels.invalid\n          : state.status === \"indeterminate\"\n            ? labels.indeterminate\n            : labels.pending\n\n    const sizeNote =\n      usage !== \"text\"\n        ? null\n        : fontSize === undefined\n          ? labels.unknownSize\n          : report?.large\n            ? labels.largeText\n            : labels.normalText\n\n    return (\n      <div\n        ref={setRef}\n        className={cn(\"flex flex-col gap-3 text-sm\", className)}\n        {...props}\n      >\n        {sample !== false && (\n          <div\n            className=\"flex min-h-16 items-center justify-center rounded-md border px-4 py-3 text-center\"\n            // The one place the colours under test are applied rather than described. `aria-hidden`\n            // because the string is a specimen, not content, and a reader already has the verdict.\n            aria-hidden=\"true\"\n            style={{\n              color: foreground,\n              background,\n              fontSize,\n              fontWeight: fontWeight as React.CSSProperties[\"fontWeight\"],\n            }}\n          >\n            {sample ?? labels.sample}\n          </div>\n        )}\n\n        <div role=\"status\" className=\"flex flex-wrap items-baseline gap-x-2 gap-y-1\">\n          {/* The figure on its own is read as a bare number; the label is what makes it a ratio. */}\n          <span className=\"sr-only\">{labels.ratioLabel}: </span>\n          <span className=\"text-2xl font-semibold tabular-nums leading-none\">{headline}</span>\n          {state.status === \"measured\" && report && (\n            <span\n              className={cn(\n                \"inline-flex items-center gap-1 font-medium\",\n                report.operative.passes ? \"text-foreground\" : \"text-destructive\",\n              )}\n            >\n              <Verdict passes={report.operative.passes} />\n              {report.operative.passes ? labels.passes : labels.fails} {report.operative.label}\n            </span>\n          )}\n          <span className=\"text-muted-foreground\">\n            {state.status === \"measured\" && report\n              ? `needs ${report.operative.required}:1${sizeNote ? ` · ${sizeNote}` : \"\"}${\n                  state.assumedBackdrop && (state.translucent.foreground || state.translucent.background)\n                    ? ` · ${labels.assumedBackdrop}`\n                    : \"\"\n                }`\n              : state.status === \"invalid\"\n                ? labels.invalidHint\n                : state.status === \"indeterminate\"\n                  ? labels.indeterminateHint\n                  : \"\"}\n          </span>\n        </div>\n\n        {detail && state.status === \"measured\" && report && (\n          <ul className=\"flex flex-col gap-1\">\n            {report.checks.map((check) => (\n              <li\n                key={check.id}\n                className={cn(\n                  \"flex items-center gap-2\",\n                  check.id === report.operative.id ? \"font-medium\" : \"text-muted-foreground\",\n                )}\n              >\n                <Verdict passes={check.passes} />\n                <span className=\"flex-1\">{check.label}</span>\n                <span className=\"tabular-nums\">{check.required}:1</span>\n                <span className=\"sr-only\">\n                  {check.passes ? labels.passes : labels.fails} — WCAG {check.criterion}\n                </span>\n              </li>\n            ))}\n          </ul>\n        )}\n      </div>\n    )\n  },\n)\n\n/**\n * The tick or cross.\n *\n * `aria-hidden`, with the word it stands for written out beside it in every place this is used —\n * shape and colour are both unavailable to some of the readers this component exists to serve.\n */\nfunction Verdict({ passes }: { passes: boolean }) {\n  const Icon = passes ? Check : X\n  return (\n    <Icon\n      aria-hidden=\"true\"\n      className={cn(\"size-4 shrink-0\", passes ? \"text-muted-foreground\" : \"text-destructive\")}\n    />\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
