{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "color-picker-engine",
  "type": "registry:ui",
  "title": "Amplo Color Picker Engine",
  "description": "Dialect-agnostic core of the Amplo color picker: the OKLCH state engine (types, conversions, gamut math, CSS Color 4 chroma reduction, WCAG + APCA contrast), the React context, and the pointer/canvas-driven parts both variants share — Root, Area, Chroma, Preview, EyeDropper, CssInput, Field, and the tooltip-agnostic GamutBadge/ContrastReadout logic. Installed automatically as a dependency of `color-picker` (Base UI) and `color-picker-radix`; you do not normally add it directly.",
  "version": "2.0.0",
  "categories": [
    "color",
    "input",
    "design",
    "accessibility"
  ],
  "dependencies": [
    "culori",
    "@types/culori",
    "lucide-react",
    "class-variance-authority"
  ],
  "registryDependencies": [
    "utils",
    "button"
  ],
  "files": [
    {
      "path": "registry/new-york/ui/fill-picker/lib/types.ts",
      "type": "registry:lib",
      "target": "@ui/fill-picker/lib/types.ts",
      "content": "/**\n * Canonical color representation used throughout the picker.\n * All values normalized; alpha is always tracked.\n */\nexport interface OklchColor {\n  /** Lightness 0..1 */\n  l: number;\n  /** Chroma 0..~0.4 (hard upper bound depends on hue + space) */\n  c: number;\n  /** Hue 0..360 (NaN when chroma === 0; treat as 0 for serialization) */\n  h: number;\n  /** Alpha 0..1 */\n  alpha: number;\n}\n\nexport type ColorFormat =\n  | \"hex\"\n  | \"rgb\"\n  | \"hsl\"\n  | \"hsb\"\n  | \"oklch\"\n  | \"oklab\"\n  | \"p3\";\n\nexport type Gamut = \"srgb\" | \"p3\" | \"rec2020\";\n\nexport interface ContrastResult {\n  /** WCAG 2.1 contrast ratio, 1..21 */\n  wcag: number;\n  /** WCAG 2.1 levels passed against the supplied background */\n  wcagLevel: { aaNormal: boolean; aaLarge: boolean; aaaNormal: boolean; aaaLarge: boolean };\n  /** APCA Lc value, signed, typical magnitude 0..108 */\n  apca: number;\n}\n\nexport interface GamutInfo {\n  inSrgb: boolean;\n  inP3: boolean;\n  inRec2020: boolean;\n}\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/lib/color.ts",
      "type": "registry:lib",
      "target": "@ui/fill-picker/lib/color.ts",
      "content": "import {\n  parse as culoriParse,\n  converter,\n  formatHex,\n  formatHex8,\n  formatRgb,\n  toGamut as culoriToGamut,\n  wcagContrast,\n  type Color,\n} from \"culori\";\nimport type { ColorFormat, ContrastResult, Gamut, GamutInfo, OklchColor } from \"./types\";\n\nconst toOklch = converter(\"oklch\");\nconst toRgb = converter(\"rgb\");\nconst toHsl = converter(\"hsl\");\nconst toHsv = converter(\"hsv\");\nconst toOklab = converter(\"oklab\");\nconst toP3 = converter(\"p3\");\n\nconst GAMUT_EPSILON = 1e-4;\n/** Chroma / lightness-edge threshold below which hue is meaningless. */\nconst ACHROMATIC_EPS = 1e-4;\n\nfunction channelsInRange(c: { r?: number; g?: number; b?: number } | undefined): boolean {\n  if (!c) return false;\n  const r = c.r ?? 0;\n  const g = c.g ?? 0;\n  const b = c.b ?? 0;\n  return (\n    r >= -GAMUT_EPSILON && r <= 1 + GAMUT_EPSILON &&\n    g >= -GAMUT_EPSILON && g <= 1 + GAMUT_EPSILON &&\n    b >= -GAMUT_EPSILON && b <= 1 + GAMUT_EPSILON\n  );\n}\n\nconst toRec2020 = converter(\"rec2020\");\n\nfunction isInSrgb(ok: { mode: \"oklch\"; l: number; c: number; h: number; alpha: number }) {\n  return channelsInRange(toRgb(ok));\n}\nfunction isInP3(ok: { mode: \"oklch\"; l: number; c: number; h: number; alpha: number }) {\n  return channelsInRange(toP3(ok));\n}\nfunction isInRec2020(ok: { mode: \"oklch\"; l: number; c: number; h: number; alpha: number }) {\n  return channelsInRange(toRec2020(ok));\n}\n\nexport interface ParsedColor {\n  color: OklchColor;\n  /**\n   * True when the source string carried no OKLCH-scale hue — an achromatic\n   * hex/rgb/hsl/p3 where the round-trip loses hue and `color.h` is a\n   * defaulted 0 or conversion noise (a p3 gray lands on c ≈ 1e-16, h 180).\n   * False whenever the hue is meaningful: chromatic colors, and OKLCH\n   * strings that author a hue even at zero chroma.\n   */\n  hueMissing: boolean;\n}\n\n/**\n * Parse a CSS color string into the canonical OKLCH form, reporting whether\n * the hue survived the round-trip. Returns null when the string can't be\n * parsed.\n */\nexport function parseColorDetailed(input: string): ParsedColor | null {\n  if (!input || typeof input !== \"string\") return null;\n  const parsed = culoriParse(input.trim());\n  if (!parsed) return null;\n  const oklch = toOklch(parsed);\n  if (!oklch) return null;\n  // Only OKLCH strings can author a hue at zero chroma; any other space's\n  // near-zero chroma is a neutral whose finite hue is float noise.\n  const hueMissing =\n    !Number.isFinite(oklch.h) ||\n    (parsed.mode !== \"oklch\" && (oklch.c ?? 0) <= ACHROMATIC_EPS);\n  return {\n    color: {\n      l: clamp(oklch.l ?? 0, 0, 1),\n      c: Math.max(oklch.c ?? 0, 0),\n      h: hueMissing ? 0 : wrapHue(oklch.h as number),\n      alpha: oklch.alpha ?? 1,\n    },\n    hueMissing,\n  };\n}\n\n/**\n * Parse a CSS color string into the canonical OKLCH form.\n * Returns null when the string can't be parsed.\n */\nexport function parseColor(input: string): OklchColor | null {\n  return parseColorDetailed(input)?.color ?? null;\n}\n\nexport function isValidColor(input: string): boolean {\n  return parseColor(input) !== null;\n}\n\n/**\n * Serialize a canonical OKLCH color to a CSS string in the chosen format.\n * For sRGB-targeted formats (hex/rgb/hsl/hsb) the color is gamut-mapped to sRGB first.\n * For P3 the color is gamut-mapped to P3.\n * OKLCH/OKLab outputs are unbounded.\n *\n * Numeric output is rounded (hue/percentages to 2 decimals, unit-range\n * channels to 4) so strings stay readable and a parent echoing `formatted`\n * back as a controlled `value` settles after one re-parse instead of\n * carrying float noise like `1.1e-14%`.\n */\nexport function formatColor(color: OklchColor, format: ColorFormat): string {\n  switch (format) {\n    case \"hex\": {\n      const mapped = mapToGamutColor(color, \"srgb\");\n      const rgb = toRgb({ mode: \"oklch\", ...oklchObj(mapped) });\n      if (!rgb) return \"#000000\";\n      return (color.alpha < 1 ? formatHex8(rgb) : formatHex(rgb)).toUpperCase();\n    }\n    case \"rgb\": {\n      const mapped = mapToGamutColor(color, \"srgb\");\n      const rgb = toRgb({ mode: \"oklch\", ...oklchObj(mapped) });\n      return rgb ? formatRgb(rgb) : \"rgb(0 0 0)\";\n    }\n    case \"hsl\": {\n      const hsl = toHsl(srgbClamped(color));\n      if (!hsl) return \"hsl(0 0% 0%)\";\n      return `hsl(${fmtHue(hsl.h)} ${round(hsl.s * 100, 2)}% ${round(hsl.l * 100, 2)}%${fmtAlpha(color.alpha)})`;\n    }\n    case \"hsb\": {\n      // CSS has no hsb()/hsv() function. Emit culori's `color(--hsv h s v)`\n      // (s/v in 0..1): not paintable by browsers, but it is the one HSB\n      // syntax `parseColor` reads back, so the Input field round-trips.\n      const hsv = toHsv(srgbClamped(color));\n      if (!hsv) return \"color(--hsv 0 0 0)\";\n      return `color(--hsv ${fmtHue(hsv.h)} ${round(hsv.s, 4)} ${round(hsv.v, 4)}${fmtAlpha(color.alpha)})`;\n    }\n    case \"oklch\": {\n      const { l, c, h, alpha } = color;\n      const lStr = round(l, 4);\n      const cStr = round(c, 4);\n      const hStr = round(h, 2);\n      return `oklch(${lStr} ${cStr} ${hStr}${fmtAlpha(alpha)})`;\n    }\n    case \"oklab\": {\n      const lab = toOklab({ mode: \"oklch\", ...oklchObj(color) });\n      if (!lab) return \"oklab(0 0 0)\";\n      return `oklab(${round(lab.l, 4)} ${round(lab.a, 4)} ${round(lab.b, 4)}${fmtAlpha(color.alpha)})`;\n    }\n    case \"p3\": {\n      const mapped = mapToGamutColor(color, \"p3\");\n      const p3 = toP3({ mode: \"oklch\", ...oklchObj(mapped) });\n      if (!p3) return \"color(display-p3 0 0 0)\";\n      return `color(display-p3 ${round(p3.r, 4)} ${round(p3.g, 4)} ${round(p3.b, 4)}${fmtAlpha(color.alpha)})`;\n    }\n  }\n}\n\n/** Hue for hsl/hsb output: undefined (achromatic) → 0, wrapped after rounding. */\nfunction fmtHue(h: number | undefined): number {\n  if (!Number.isFinite(h)) return 0;\n  const r = round(wrapHue(h as number), 2);\n  return r >= 360 ? 0 : r;\n}\n\n/** Alpha suffix for space-separated CSS syntax; empty when opaque. */\nfunction fmtAlpha(alpha: number): string {\n  return alpha < 1 ? ` / ${round(alpha, 3)}` : \"\";\n}\n\n/** Convenience: pull bare OKLCH numeric fields. */\nfunction oklchObj(c: OklchColor) {\n  return { l: c.l, c: c.c, h: c.h, alpha: c.alpha };\n}\n\nconst ALL_FORMATS: ColorFormat[] = [\n  \"hex\",\n  \"rgb\",\n  \"hsl\",\n  \"hsb\",\n  \"oklch\",\n  \"oklab\",\n  \"p3\",\n];\n\n/**\n * Serialize an OKLCH color to every supported output format at once.\n * Useful when consumers need both the canonical lossless form (oklch) and\n * a fallback string (hex) without re-running format selection.\n */\nexport function formatAll(color: OklchColor): Record<ColorFormat, string> {\n  const out = {} as Record<ColorFormat, string>;\n  for (const f of ALL_FORMATS) out[f] = formatColor(color, f);\n  return out;\n}\n\n/**\n * Continuous signed distance from the target gamut surface.\n *  - 0 on the boundary\n *  - <0 inside the gamut (any channel exceeds [0,1] by the most-negative amount)\n *  - >0 outside the gamut (channels exceed by the most-positive amount)\n *\n * Drives the marching-squares boundary line. We use inline OKLab → linear RGB\n * math (zero crossings unchanged from gamma-encoded RGB) so the per-grid-point\n * cost stays under a microsecond — at 128² × 2 lines per OKLCH-mode repaint\n * the culori path was the new perf hot spot.\n */\nexport function gamutSignedDistance(color: OklchColor, gamut: Gamut): number {\n  const lin = oklchToLinearSrgb(color.l, color.c, color.h);\n  const target =\n    gamut === \"srgb\"\n      ? lin\n      : gamut === \"p3\"\n        ? linSrgbToLinP3(lin)\n        : linSrgbToLinRec2020(lin);\n  const lo = -Math.min(target.r, target.g, target.b);\n  const hi = Math.max(target.r, target.g, target.b) - 1;\n  return Math.max(lo, hi);\n}\n\n/**\n * OKLCH → linear sRGB via Björn Ottosson's OKLab matrix. Output channels can\n * be negative or >1 for colors outside sRGB; that's the point — wider gamuts\n * are just `linSrgbToLinP3` / `linSrgbToLinRec2020` away.\n */\nexport function oklchToLinearSrgb(\n  l: number,\n  c: number,\n  hDeg: number,\n): { r: number; g: number; b: number } {\n  const h = (hDeg * Math.PI) / 180;\n  const a = c * Math.cos(h);\n  const b = c * Math.sin(h);\n\n  const lp = l + 0.3963377774 * a + 0.2158037573 * b;\n  const mp = l - 0.1055613458 * a - 0.0638541728 * b;\n  const sp = l - 0.0894841775 * a - 1.291485548 * b;\n\n  const L = lp * lp * lp;\n  const M = mp * mp * mp;\n  const S = sp * sp * sp;\n\n  return {\n    r: 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S,\n    g: -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S,\n    b: -0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S,\n  };\n}\n\n/* Linear sRGB → linear Display-P3 (CSS Color 4 reference matrix). */\nexport function linSrgbToLinP3(c: {\n  r: number;\n  g: number;\n  b: number;\n}): { r: number; g: number; b: number } {\n  return {\n    r: 0.8224621 * c.r + 0.1775380 * c.g + 0.0 * c.b,\n    g: 0.0331942 * c.r + 0.9668058 * c.g + 0.0 * c.b,\n    b: 0.0170828 * c.r + 0.0723976 * c.g + 0.9105196 * c.b,\n  };\n}\n\n/* Linear sRGB → linear Rec.2020 (CSS Color 4 reference matrix). */\nexport function linSrgbToLinRec2020(c: {\n  r: number;\n  g: number;\n  b: number;\n}): { r: number; g: number; b: number } {\n  return {\n    r: 0.6274039 * c.r + 0.3292830 * c.g + 0.0433131 * c.b,\n    g: 0.0690973 * c.r + 0.9195404 * c.g + 0.0113623 * c.b,\n    b: 0.0163914 * c.r + 0.0880133 * c.g + 0.8955953 * c.b,\n  };\n}\n\n/**\n * Bisect for the maximum OKLCH chroma at this lightness and hue that still\n * fits inside the target gamut. Returns 0 at L=0 or L=1 (those are achromatic\n * by definition). Cost: ~12 iterations × 3 matrix-mults per call.\n *\n * `epsilon` controls the bisection's strictness — a positive value relaxes\n * the in-gamut check (allows channels out by `epsilon`); zero is strict; a\n * negative value shrinks the gamut. Default 0 is strict so the returned\n * chroma is *guaranteed* in-gamut; the area's bead-clamp re-pin and\n * culori-based `gamutInfo` (which use their own `GAMUT_EPSILON` tolerance)\n * will then never tip the picked color out.\n *\n * The `marginalChroma` knob trims an absolute amount off the final result\n * to absorb bisection precision (~5e-5) plus OKLab matrix drift. Without it,\n * the result can sit on the strict surface where the round-trip via culori's\n * `toGamut` and the area's hue re-pin can push it out by float ULPs.\n */\nexport function findMaxChroma(\n  l: number,\n  hDeg: number,\n  gamut: Gamut,\n  options: { iterations?: number; epsilon?: number; marginalChroma?: number } = {},\n): number {\n  if (l <= 0 || l >= 1) return 0;\n  const iterations = options.iterations ?? 14;\n  const eps = options.epsilon ?? 0;\n  const marginalChroma = options.marginalChroma ?? 1e-3;\n\n  const inGamut = (c: number): boolean => {\n    const lin = oklchToLinearSrgb(l, c, hDeg);\n    const target =\n      gamut === \"srgb\"\n        ? lin\n        : gamut === \"p3\"\n          ? linSrgbToLinP3(lin)\n          : linSrgbToLinRec2020(lin);\n    return (\n      target.r >= -eps && target.r <= 1 + eps &&\n      target.g >= -eps && target.g <= 1 + eps &&\n      target.b >= -eps && target.b <= 1 + eps\n    );\n  };\n\n  // Find an upper bound that is definitely outside. 0.5 covers Rec.2020 reds\n  // (max chroma ≈ 0.4 in OKLCH); double until out, capped at 2.0 for safety.\n  let hi = 0.5;\n  while (inGamut(hi) && hi < 2) hi *= 2;\n  let lo = 0;\n  for (let i = 0; i < iterations; i++) {\n    const mid = (lo + hi) / 2;\n    if (inGamut(mid)) lo = mid;\n    else hi = mid;\n  }\n  return Math.max(0, lo - marginalChroma);\n}\n\n/**\n * Mildly saturated OKLCH (l, c) at which an achromatic color's hue is read\n * on the HSL/HSV scale. A gray's own HSL/HSV hue is undefined or float\n * noise, so the slider, the channel input's H field (`lib/channels.ts`), and\n * hue writes all read it off this probe on the stored OKLCH hue instead —\n * sharing one constant keeps the three in agreement.\n */\nexport const ACHROMATIC_HUE_PROBE = { l: 0.6, c: 0.08 } as const;\n\nfunction isAchromatic(color: OklchColor): boolean {\n  return (\n    color.c <= ACHROMATIC_EPS ||\n    color.l <= ACHROMATIC_EPS ||\n    color.l >= 1 - ACHROMATIC_EPS\n  );\n}\n\n/**\n * Hue of an OKLCH color in HSL's hue scale (degrees). Used by the Hue slider\n * when the active format is `hsl` so the slider position matches what the\n * channel input shows — OKLCH hue and HSL hue diverge for the same color\n * (red is OKLCH ~29° but HSL 0°). Achromatic colors are read through\n * `ACHROMATIC_HUE_PROBE` so the slider tracks the stored hue.\n */\nexport function hslHue(color: OklchColor): number {\n  const src = isAchromatic(color) ? { ...color, ...ACHROMATIC_HUE_PROBE } : color;\n  const c = toHsl({ mode: \"oklch\", l: src.l, c: src.c, h: src.h });\n  return c?.h ?? color.h;\n}\n\n/**\n * Hue of an OKLCH color in HSB/HSV's hue scale (degrees). See `hslHue`.\n */\nexport function hsbHue(color: OklchColor): number {\n  const src = isAchromatic(color) ? { ...color, ...ACHROMATIC_HUE_PROBE } : color;\n  const c = toHsv({ mode: \"oklch\", l: src.l, c: src.c, h: src.h });\n  return c?.h ?? color.h;\n}\n\n/**\n * Map an output format to the gamut whose surface fills the picker's area\n * and bounds the lossless saturation-preserving updates from the hue and\n * lightness sliders. OKLCH and OKLab are unbounded as serialization formats\n * but still need a finite picking gamut — Rec.2020 covers everything that\n * fits inside `<canvas colorSpace=\"display-p3\">` plus the slice that won't\n * paint accurately on any current monitor.\n */\nexport function gamutFromFormat(f: ColorFormat): Gamut {\n  switch (f) {\n    case \"hex\":\n    case \"rgb\":\n    case \"hsl\":\n    case \"hsb\":\n      return \"srgb\";\n    case \"p3\":\n      return \"p3\";\n    case \"oklch\":\n    case \"oklab\":\n      return \"rec2020\";\n  }\n}\n\n/**\n * Find the OKLCH \"cusp\" for a given hue and gamut — the (L, C) point of\n * maximum chroma. Used by the `hsv-sv` Area mode to map (S, V) onto a fully\n * gamut-filling square: V=1, S=1 lands on the cusp; V=1, S=0 is white;\n * V=0 is black.\n *\n * Two-stage search: a 32-step coarse sweep over L, then a 20-step fine sweep\n * around the best candidate. ~52 `findMaxChroma` calls per cusp lookup,\n * which is cheap enough to call once per repaint per active hue.\n */\nexport function findCusp(\n  hDeg: number,\n  gamut: Gamut,\n): { l: number; c: number } {\n  let bestL = 0.5;\n  let bestC = 0;\n  for (let i = 1; i < 32; i++) {\n    const l = i / 32;\n    const c = findMaxChroma(l, hDeg, gamut);\n    if (c > bestC) {\n      bestC = c;\n      bestL = l;\n    }\n  }\n  const lo = Math.max(0.001, bestL - 1 / 32);\n  const hi = Math.min(0.999, bestL + 1 / 32);\n  for (let i = 0; i <= 20; i++) {\n    const l = lo + ((hi - lo) * i) / 20;\n    const c = findMaxChroma(l, hDeg, gamut);\n    if (c > bestC) {\n      bestC = c;\n      bestL = l;\n    }\n  }\n  return { l: bestL, c: bestC };\n}\n\nexport function gamutInfo(color: OklchColor): GamutInfo {\n  const ok = { mode: \"oklch\" as const, ...oklchObj(color) };\n  return {\n    inSrgb: isInSrgb(ok),\n    inP3: isInP3(ok),\n    inRec2020: isInRec2020(ok),\n  };\n}\n\n/**\n * Gamut-map an OKLCH color to the target gamut using CSS Color 4's\n * chroma-reduction-in-OKLCH algorithm (culori `toGamut`).\n * Returns canonical OKLCH form.\n */\nexport function toGamut(color: OklchColor, gamut: Gamut): OklchColor {\n  return mapToGamutColor(color, gamut);\n}\n\n/**\n * sRGB channels of `color` after gamut mapping, clamped to [0, 1]. The\n * mapper tolerates `GAMUT_EPSILON` of channel slack, which near black is a\n * large relative error: fed to HSL/HSV it becomes saturation in the\n * millions. Channels are also snapped to 1e-6 (far below 8-bit precision):\n * near white HSL saturation divides by 1 - |2L - 1| ≈ 0, so 1e-7 of\n * conversion noise on #fff would otherwise read as 50%. HSL/HSV output\n * strings and channel readouts read through this.\n */\nexport function srgbClamped(color: OklchColor) {\n  const rgb = toRgb({ mode: \"oklch\", ...oklchObj(mapToGamutColor(color, \"srgb\")) });\n  const ch = (v: number | undefined) => round(clamp(v ?? 0, 0, 1), 6);\n  return { mode: \"rgb\" as const, r: ch(rgb?.r), g: ch(rgb?.g), b: ch(rgb?.b) };\n}\n\nfunction mapToGamutColor(color: OklchColor, gamut: Gamut): OklchColor {\n  const ok = { mode: \"oklch\" as const, ...oklchObj(color) };\n  // culori's gamut test is strict, so an in-gamut color whose OKLCH\n  // round-trip leaves a channel at -1e-14 gets chroma-reduced, nudging it\n  // off its own value (#008197 → #018197). Honor the same tolerance as\n  // `gamutInfo` and leave such colors untouched (`srgbClamped` clamps the\n  // leftover slack for HSL/HSV output).\n  const inGamut =\n    gamut === \"srgb\" ? isInSrgb(ok) : gamut === \"p3\" ? isInP3(ok) : isInRec2020(ok);\n  if (inGamut) return color;\n  const targetMode = gamut === \"srgb\" ? \"rgb\" : gamut === \"p3\" ? \"p3\" : \"rec2020\";\n  const mapper = culoriToGamut(targetMode, \"oklch\");\n  const mapped = mapper(ok) as Color | undefined;\n  if (!mapped) return color;\n  const back = toOklch(mapped);\n  if (!back) return color;\n  return {\n    l: back.l ?? color.l,\n    c: Math.max(back.c ?? 0, 0),\n    h: Number.isFinite(back.h) ? (back.h as number) : color.h,\n    alpha: color.alpha,\n  };\n}\n\n/**\n * Composite fg (with alpha) over an opaque bg in gamma-encoded sRGB — the\n * same space browsers blend in by default, so the result matches what's\n * painted.\n */\nfunction compositeOnBg(fg: OklchColor, bg: OklchColor): OklchColor {\n  if (fg.alpha >= 1) return fg;\n  const fgRgb = toRgb({ mode: \"oklch\", ...oklchObj(fg) });\n  const bgRgb = toRgb({ mode: \"oklch\", ...oklchObj(bg) });\n  if (!fgRgb || !bgRgb) return fg;\n  const a = fg.alpha;\n  const out = {\n    mode: \"rgb\" as const,\n    r: (fgRgb.r ?? 0) * a + (bgRgb.r ?? 0) * (1 - a),\n    g: (fgRgb.g ?? 0) * a + (bgRgb.g ?? 0) * (1 - a),\n    b: (fgRgb.b ?? 0) * a + (bgRgb.b ?? 0) * (1 - a),\n    alpha: 1,\n  };\n  const oklch = toOklch(out)!;\n  return {\n    l: oklch.l ?? 0,\n    c: Math.max(oklch.c ?? 0, 0),\n    h: Number.isFinite(oklch.h) ? (oklch.h as number) : 0,\n    alpha: 1,\n  };\n}\n\nexport function contrast(fg: OklchColor, bg: OklchColor): ContrastResult {\n  const composedFg = compositeOnBg(fg, { ...bg, alpha: 1 });\n  const fgC = toRgb({ mode: \"oklch\", ...oklchObj(composedFg) });\n  const bgC = toRgb({ mode: \"oklch\", ...oklchObj({ ...bg, alpha: 1 }) });\n  const ratio = fgC && bgC ? wcagContrast(fgC, bgC) : 1;\n  const safe = Number.isFinite(ratio) ? ratio : 1;\n  return {\n    wcag: round(safe, 2),\n    wcagLevel: {\n      aaNormal: safe >= 4.5,\n      aaLarge: safe >= 3,\n      aaaNormal: safe >= 7,\n      aaaLarge: safe >= 4.5,\n    },\n    apca: apcaContrast(composedFg, { ...bg, alpha: 1 }),\n  };\n}\n\n/**\n * APCA Lc value per APCA-W3 0.1.9 (SACAM 0.98G).\n * Reference: https://github.com/Myndex/SAPC-APCA\n * Returned value is rounded to 2 decimals.\n *  - Positive Lc: dark text on light bg\n *  - Negative Lc: light text on dark bg\n */\nexport function apcaContrast(fg: OklchColor, bg: OklchColor): number {\n  const fgRgb = toRgb({ mode: \"oklch\", ...oklchObj(fg) });\n  const bgRgb = toRgb({ mode: \"oklch\", ...oklchObj(bg) });\n  if (!fgRgb || !bgRgb) return 0;\n\n  const Ytxt = sapcLuminance(fgRgb.r ?? 0, fgRgb.g ?? 0, fgRgb.b ?? 0);\n  const Ybg = sapcLuminance(bgRgb.r ?? 0, bgRgb.g ?? 0, bgRgb.b ?? 0);\n\n  return round(sapcContrast(Ytxt, Ybg), 2);\n}\n\nconst SA98G = {\n  mainTRC: 2.4,\n  Rco: 0.2126729,\n  Gco: 0.7151522,\n  Bco: 0.072175,\n  normBG: 0.56,\n  normTXT: 0.57,\n  revTXT: 0.62,\n  revBG: 0.65,\n  blkThrs: 0.022,\n  blkClmp: 1.414,\n  scaleBoW: 1.14,\n  scaleWoB: 1.14,\n  loBoWoffset: 0.027,\n  loWoBoffset: 0.027,\n  deltaYmin: 0.0005,\n  loClip: 0.1,\n};\n\nfunction sapcLuminance(r: number, g: number, b: number): number {\n  const R = clamp(r, 0, 1) ** SA98G.mainTRC;\n  const G = clamp(g, 0, 1) ** SA98G.mainTRC;\n  const B = clamp(b, 0, 1) ** SA98G.mainTRC;\n  return SA98G.Rco * R + SA98G.Gco * G + SA98G.Bco * B;\n}\n\nfunction sapcContrast(Ytxt: number, Ybg: number): number {\n  const txt = Ytxt < SA98G.blkThrs\n    ? Ytxt + (SA98G.blkThrs - Ytxt) ** SA98G.blkClmp\n    : Ytxt;\n  const bg = Ybg < SA98G.blkThrs\n    ? Ybg + (SA98G.blkThrs - Ybg) ** SA98G.blkClmp\n    : Ybg;\n\n  if (Math.abs(bg - txt) < SA98G.deltaYmin) return 0;\n\n  let SAPC = 0;\n  let outputContrast = 0;\n\n  if (bg > txt) {\n    // Dark text on light background\n    SAPC = (bg ** SA98G.normBG - txt ** SA98G.normTXT) * SA98G.scaleBoW;\n    outputContrast = SAPC < SA98G.loClip ? 0 : SAPC - SA98G.loBoWoffset;\n  } else {\n    // Light text on dark background\n    SAPC = (bg ** SA98G.revBG - txt ** SA98G.revTXT) * SA98G.scaleWoB;\n    outputContrast = SAPC > -SA98G.loClip ? 0 : SAPC + SA98G.loWoBoffset;\n  }\n\n  return outputContrast * 100;\n}\n\nfunction clamp(x: number, lo: number, hi: number) {\n  return Math.min(Math.max(x, lo), hi);\n}\n\nfunction round(x: number, dp: number) {\n  const f = 10 ** dp;\n  return Math.round(x * f) / f;\n}\n\nfunction wrapHue(h: number) {\n  return ((h % 360) + 360) % 360;\n}\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/lib/channels.ts",
      "type": "registry:lib",
      "target": "@ui/fill-picker/lib/channels.ts",
      "content": "import { converter, type Color } from \"culori\";\nimport type { ColorFormat, OklchColor } from \"./types\";\nimport {\n  ACHROMATIC_HUE_PROBE,\n  findMaxChroma,\n  gamutFromFormat,\n  srgbClamped,\n  toGamut,\n} from \"./color\";\n\nconst toOklch = converter(\"oklch\");\nconst toRgb = converter(\"rgb\");\nconst toHsl = converter(\"hsl\");\nconst toHsv = converter(\"hsv\");\nconst toOklab = converter(\"oklab\");\nconst toP3 = converter(\"p3\");\n\nexport interface ChannelDescriptor {\n  /** Internal key used by setColorChannel. */\n  key: string;\n  /** One- or two-letter UI label (R, G, B, H, S, L, etc.). */\n  label: string;\n  /** Current value in display units (e.g. RGB 0–255, OKLCH L 0–100). */\n  value: number;\n  min: number;\n  /** Hard edit bound; `Infinity` for channels unbounded in their space\n   * (OKLCH chroma). Clamping inputs must skip non-finite bounds. */\n  max: number;\n  /** Arrow-key step. */\n  step: number;\n  /** Shift+arrow step. */\n  bigStep: number;\n  /** Decimals to render. */\n  precision: number;\n  /** Optional unit suffix shown after the field. */\n  suffix?: string;\n}\n\nconst ALPHA_DESCRIPTOR = (alpha: number): ChannelDescriptor => ({\n  key: \"alpha\",\n  label: \"α\",\n  value: round(alpha * 100, 0),\n  min: 0,\n  max: 100,\n  step: 1,\n  bigStep: 10,\n  precision: 0,\n  suffix: \"%\",\n});\n\n/**\n * Per-format channel descriptors for the multi-field input. Hex returns an\n * empty array — render hex as a single text field instead.\n */\nexport function colorChannels(\n  color: OklchColor,\n  format: ColorFormat,\n): ChannelDescriptor[] {\n  switch (format) {\n    case \"hex\":\n      return [];\n    case \"rgb\": {\n      const rgb = toRgb({\n        mode: \"oklch\",\n        ...oklchObj(toGamut(color, \"srgb\")),\n      });\n      const r = round((rgb?.r ?? 0) * 255, 0);\n      const g = round((rgb?.g ?? 0) * 255, 0);\n      const b = round((rgb?.b ?? 0) * 255, 0);\n      return [\n        intChannel(\"r\", \"R\", r, 0, 255),\n        intChannel(\"g\", \"G\", g, 0, 255),\n        intChannel(\"b\", \"B\", b, 0, 255),\n        ALPHA_DESCRIPTOR(color.alpha),\n      ];\n    }\n    case \"hsl\": {\n      const hsl = toHsl(srgbClamped(color));\n      return [\n        intChannel(\"h\", \"H\", round(stableFormatHue(toHsl, color, hsl?.h), 0), 0, 360),\n        intChannel(\"s\", \"S\", round((hsl?.s ?? 0) * 100, 0), 0, 100, \"%\"),\n        intChannel(\"l\", \"L\", round((hsl?.l ?? 0) * 100, 0), 0, 100, \"%\"),\n        ALPHA_DESCRIPTOR(color.alpha),\n      ];\n    }\n    case \"hsb\": {\n      const hsv = toHsv(srgbClamped(color));\n      return [\n        intChannel(\"h\", \"H\", round(stableFormatHue(toHsv, color, hsv?.h), 0), 0, 360),\n        intChannel(\"s\", \"S\", round((hsv?.s ?? 0) * 100, 0), 0, 100, \"%\"),\n        intChannel(\"b\", \"B\", round((hsv?.v ?? 0) * 100, 0), 0, 100, \"%\"),\n        ALPHA_DESCRIPTOR(color.alpha),\n      ];\n    }\n    case \"oklch\":\n      return [\n        intChannel(\"l\", \"L\", round(color.l * 100, 0), 0, 100, \"%\"),\n        // Chroma is unbounded above at edit time (gamut limits apply at\n        // display via toGamut) — a finite max here would let clamping\n        // number fields destroy wide-gamut values.\n        floatChannel(\"c\", \"C\", round(color.c, 3), 0, Infinity, 0.005, 0.05, 3),\n        intChannel(\"h\", \"H\", round(color.h, 0), 0, 360),\n        ALPHA_DESCRIPTOR(color.alpha),\n      ];\n    case \"oklab\": {\n      const lab = toOklab({ mode: \"oklch\", ...oklchObj(color) });\n      return [\n        intChannel(\"l\", \"L\", round((lab?.l ?? color.l) * 100, 0), 0, 100, \"%\"),\n        floatChannel(\"a\", \"a\", round(lab?.a ?? 0, 3), -0.5, 0.5, 0.005, 0.05, 3),\n        floatChannel(\"b\", \"b\", round(lab?.b ?? 0, 3), -0.5, 0.5, 0.005, 0.05, 3),\n        ALPHA_DESCRIPTOR(color.alpha),\n      ];\n    }\n    case \"p3\": {\n      const p3 = toP3({\n        mode: \"oklch\",\n        ...oklchObj(toGamut(color, \"p3\")),\n      });\n      return [\n        floatChannel(\"r\", \"R\", round(p3?.r ?? 0, 3), 0, 1, 0.01, 0.1, 3),\n        floatChannel(\"g\", \"G\", round(p3?.g ?? 0, 3), 0, 1, 0.01, 0.1, 3),\n        floatChannel(\"b\", \"B\", round(p3?.b ?? 0, 3), 0, 1, 0.01, 0.1, 3),\n        ALPHA_DESCRIPTOR(color.alpha),\n      ];\n    }\n  }\n}\n\n/**\n * Replace one channel's value in the active format's space and convert the\n * result back to canonical OKLCH. Display-unit input (e.g. RGB 0–255, OKLCH\n * L 0–100, alpha 0–100) — colorChannels and this writer agree on units.\n */\nexport function setColorChannel(\n  color: OklchColor,\n  format: ColorFormat,\n  key: string,\n  value: number,\n): OklchColor {\n  // A cleared or garbage numeric field can surface as NaN; storing it would\n  // poison every downstream conversion.\n  if (!Number.isFinite(value)) return color;\n  if (key === \"alpha\") {\n    return { ...color, alpha: clamp(value / 100, 0, 1) };\n  }\n  switch (format) {\n    case \"hex\":\n      // Hex isn't channel-addressable; callers should special-case it.\n      return color;\n    case \"rgb\": {\n      const rgb = toRgb({\n        mode: \"oklch\",\n        ...oklchObj(toGamut(color, \"srgb\")),\n      }) ?? { r: 0, g: 0, b: 0 };\n      const next = {\n        ...rgb,\n        [key]: clamp(value / 255, 0, 1),\n        mode: \"rgb\" as const,\n      };\n      return fromCulori(next, color.alpha, color.h);\n    }\n    case \"hsl\": {\n      const hsl = toHsl({\n        mode: \"oklch\",\n        ...oklchObj(toGamut(color, \"srgb\")),\n      }) ?? { h: 0, s: 0, l: 0 };\n      if (key === \"h\" && isAchromatic(color.l, color.c)) {\n        return { ...color, h: oklchHueForFormatHue(\"hsl\", wrap(value, 360), color.h) };\n      }\n      const h = key === \"h\" ? wrap(value, 360) : stableFormatHue(toHsl, color, hsl.h);\n      const s = key === \"s\" ? clamp(value / 100, 0, 1) : hsl.s;\n      const l = key === \"l\" ? clamp(value / 100, 0, 1) : hsl.l;\n      return fromCulori({ mode: \"hsl\" as const, h, s, l }, color.alpha, color.h);\n    }\n    case \"hsb\": {\n      const hsv = toHsv({\n        mode: \"oklch\",\n        ...oklchObj(toGamut(color, \"srgb\")),\n      }) ?? { h: 0, s: 0, v: 0 };\n      if (key === \"h\" && isAchromatic(color.l, color.c)) {\n        return { ...color, h: oklchHueForFormatHue(\"hsv\", wrap(value, 360), color.h) };\n      }\n      const h = key === \"h\" ? wrap(value, 360) : stableFormatHue(toHsv, color, hsv.h);\n      const s = key === \"s\" ? clamp(value / 100, 0, 1) : hsv.s;\n      const v = key === \"b\" ? clamp(value / 100, 0, 1) : hsv.v;\n      return fromCulori({ mode: \"hsv\" as const, h, s, v }, color.alpha, color.h);\n    }\n    case \"oklch\": {\n      switch (key) {\n        case \"l\":\n          return { ...color, l: clamp(value / 100, 0, 1) };\n        case \"c\":\n          // OKLCH chroma is unbounded above; gamut limits are applied at\n          // display time via toGamut, never at channel-edit time.\n          return { ...color, c: Math.max(value, 0) };\n        case \"h\":\n          return { ...color, h: wrap(value, 360) };\n        default:\n          return color;\n      }\n    }\n    case \"oklab\": {\n      const lab = toOklab({ mode: \"oklch\", ...oklchObj(color) }) ?? {\n        l: 0,\n        a: 0,\n        b: 0,\n      };\n      const l = key === \"l\" ? clamp(value / 100, 0, 1) : (lab.l ?? 0);\n      const a = key === \"a\" ? clamp(value, -0.5, 0.5) : (lab.a ?? 0);\n      const b = key === \"b\" ? clamp(value, -0.5, 0.5) : (lab.b ?? 0);\n      return fromCulori({ mode: \"oklab\" as const, l, a, b }, color.alpha, color.h);\n    }\n    case \"p3\": {\n      const p3 = toP3({\n        mode: \"oklch\",\n        ...oklchObj(toGamut(color, \"p3\")),\n      }) ?? { r: 0, g: 0, b: 0 };\n      const next = {\n        ...p3,\n        [key]: clamp(value, 0, 1),\n        mode: \"p3\" as const,\n      };\n      return fromCulori(next, color.alpha, color.h);\n    }\n  }\n}\n\n/**\n * Next color for a hue-slider commit. Shared by both `<ColorPicker.Hue>`\n * variants (classic and Base UI) so the two can't drift apart.\n *\n * Two paths:\n *   - HSL/HSB — write the hue through the active format so the channel\n *     input's H matches the slider exactly (no OKLCH↔HSL hue drift). On an\n *     achromatic color this stores the latent OKLCH hue that reads back as\n *     `newHue` (see `oklchHueForFormatHue`).\n *   - everything else — rescale chroma to preserve \"saturation\", i.e. the\n *     color's chroma as a fraction of the max chroma available at\n *     (l, hue, gamut). Max chroma moves with hue (green has far less than\n *     red in P3), so preserving *absolute* chroma would walk the color out\n *     of the active gamut as the user scrolls. Preserving the ratio keeps\n *     the area bead's X position — and the gamut badge — put.\n *\n * `newHue` may be any real number; it is wrapped into [0, 360). A\n * non-finite `newHue` returns `color` unchanged.\n */\nexport function setHueFromSlider(\n  color: OklchColor,\n  newHue: number,\n  format: ColorFormat,\n): OklchColor {\n  if (!Number.isFinite(newHue)) return color;\n  const wrapped = wrap(newHue, 360);\n  if (format === \"hsl\" || format === \"hsb\") {\n    return setColorChannel(color, format, \"h\", wrapped);\n  }\n  const gamut = gamutFromFormat(format);\n  const oldMaxC = findMaxChroma(color.l, color.h, gamut);\n  const newMaxC = findMaxChroma(color.l, wrapped, gamut);\n  const saturation = oldMaxC > 1e-6 ? color.c / oldMaxC : 0;\n  return { ...color, h: wrapped, c: saturation * newMaxC };\n}\n\nconst ACHROMATIC_EPS = 1e-4;\n\nfunction isAchromatic(l: number, c: number): boolean {\n  return (\n    c <= ACHROMATIC_EPS || l <= ACHROMATIC_EPS || l >= 1 - ACHROMATIC_EPS\n  );\n}\n\n/**\n * Convert an edited culori color back to canonical OKLCH, preserving\n * `fallbackHue` (the pre-edit hue) when the result is achromatic — there\n * culori's hue is undefined or numerically meaningless, and storing it\n * would destroy the user's hue (e.g. HSL s → 0 → 50 snapping blue to red).\n * Chroma is the only axis allowed to be lossy; hue must round-trip.\n */\nfunction fromCulori(c: Color, alpha: number, fallbackHue: number): OklchColor {\n  const ok = toOklch(c);\n  if (!ok) return { l: 0, c: 0, h: fallbackHue, alpha };\n  const chroma = Math.max(ok.c ?? 0, 0);\n  const l = ok.l ?? 0;\n  return {\n    l,\n    c: chroma,\n    h:\n      isAchromatic(l, chroma) || !Number.isFinite(ok.h)\n        ? fallbackHue\n        : (ok.h as number),\n    alpha,\n  };\n}\n\n/**\n * Hue of `color` in the target format's own hue scale (HSL/HSV degrees),\n * stable at the achromatic point: when chroma is ~0 the direct conversion\n * yields an undefined or garbage hue, so probe a slightly-saturated color\n * on the same OKLCH hue instead. Keeps the H field and re-saturation edits\n * anchored to the hue the user last had.\n */\nfunction stableFormatHue(\n  convert: (c: Color) => { h?: number } | undefined,\n  color: OklchColor,\n  raw: number | undefined,\n): number {\n  if (!isAchromatic(color.l, color.c) && Number.isFinite(raw)) {\n    return raw as number;\n  }\n  const probe = convert({ mode: \"oklch\", ...ACHROMATIC_HUE_PROBE, h: color.h });\n  return probe?.h ?? color.h;\n}\n\n/**\n * Inverse of the achromatic probe in `stableFormatHue`: the OKLCH hue whose\n * probe color reads `target` on the HSL/HSV hue scale. An achromatic color\n * has no format hue to write through, so an H edit stores this latent OKLCH\n * hue instead — otherwise the edit is a silent no-op. Fixed-point iteration:\n * keep the probe's s/l (or s/v) and swap in the target hue; converges to\n * well under 0.01° in a few steps because s/l barely move with hue.\n */\nfunction oklchHueForFormatHue(\n  mode: \"hsl\" | \"hsv\",\n  target: number,\n  guess: number,\n): number {\n  let h = guess;\n  for (let i = 0; i < 6; i++) {\n    const probe = { mode: \"oklch\" as const, ...ACHROMATIC_HUE_PROBE, h };\n    const back =\n      mode === \"hsl\"\n        ? toOklch({ ...toHsl(probe), h: target })\n        : toOklch({ ...toHsv(probe), h: target });\n    if (!back || !Number.isFinite(back.h)) break;\n    h = wrap(back.h as number, 360);\n  }\n  return h;\n}\n\nfunction oklchObj(c: OklchColor) {\n  return { l: c.l, c: c.c, h: c.h, alpha: c.alpha };\n}\n\nfunction intChannel(\n  key: string,\n  label: string,\n  value: number,\n  min: number,\n  max: number,\n  suffix?: string,\n): ChannelDescriptor {\n  return {\n    key,\n    label,\n    value,\n    min,\n    max,\n    step: 1,\n    bigStep: 10,\n    precision: 0,\n    suffix,\n  };\n}\n\nfunction floatChannel(\n  key: string,\n  label: string,\n  value: number,\n  min: number,\n  max: number,\n  step: number,\n  bigStep: number,\n  precision: number,\n  suffix?: string,\n): ChannelDescriptor {\n  return { key, label, value, min, max, step, bigStep, precision, suffix };\n}\n\nfunction clamp(v: number, min: number, max: number) {\n  return v < min ? min : v > max ? max : v;\n}\n\nfunction wrap(v: number, mod: number) {\n  return ((v % mod) + mod) % mod;\n}\n\nfunction round(v: number, precision: number) {\n  const m = 10 ** precision;\n  return Math.round(v * m) / m;\n}\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/lib/constants.ts",
      "type": "registry:lib",
      "target": "@ui/fill-picker/lib/constants.ts",
      "content": "/**\n * Shared alpha-transparency checkerboard backgrounds (inline SVG data URIs).\n * Two sizes on purpose: 12px tiles for large surfaces (alpha slider, preview,\n * gradient area/overlay), 8px for compact chips (swatches, stop rows,\n * contrast readout). Pair the SM variant with `backgroundSize: \"8px 8px\"`.\n */\nexport const CHECKERBOARD_LG =\n  \"url(\\\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><rect width='6' height='6' fill='%23ccc'/><rect x='6' y='6' width='6' height='6' fill='%23ccc'/></svg>\\\")\";\n\nexport const CHECKERBOARD_SM =\n  \"url(\\\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'><rect width='4' height='4' fill='%23ccc'/><rect x='4' y='4' width='4' height='4' fill='%23ccc'/></svg>\\\")\";\n\n/**\n * Edge treatment for sample chips — swatches, gradient presets, the preview.\n *\n * These used to carry `border border-border`, which is wrong for a color\n * sample in two ways: an outer border steals a pixel from the color itself,\n * and because it paints a fixed opaque grey *outside* the sample it reads as\n * a halo — glaring around a dark chip on a light surface, and vanishing\n * entirely around a dark chip on a dark surface, which is exactly backwards\n * from where an edge is needed.\n *\n * Instead draw a translucent hairline *inside* the chip, as an `::after`\n * overlay so it sits above whatever fills the tile (the swatch's color span,\n * the preview's stacked fg/bg layers). Being translucent, it disappears into\n * a saturated or dark color — which is correct, since those already separate\n * from the surface — and only asserts itself on the pale samples that would\n * otherwise dissolve into it. The tone flips per theme so a near-white chip\n * stays defined on a light surface and a near-black chip on a dark one.\n *\n * Apply to a `relative` element; the chip's own `rounded-*` is inherited.\n */\nexport const SAMPLE_EDGE =\n  \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:content-[''] \" +\n  \"after:shadow-[inset_0_0_0_1px_rgb(0_0_0/0.16)] dark:after:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.18)]\";\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/lib/swatch-presets.ts",
      "type": "registry:lib",
      "target": "@ui/fill-picker/lib/swatch-presets.ts",
      "content": "import type { OklchColor } from \"./types\";\n\n/** Default swatch presets shared by both Swatches variants. */\nexport const DEFAULT_SWATCH_PRESETS = [\n  \"oklch(0.95 0 0)\",\n  \"oklch(0.75 0 0)\",\n  \"oklch(0.5 0 0)\",\n  \"oklch(0.25 0 0)\",\n  \"oklch(0.05 0 0)\",\n  \"oklch(0.7 0.18 30)\",\n  \"oklch(0.7 0.18 90)\",\n  \"oklch(0.7 0.18 150)\",\n  \"oklch(0.7 0.18 210)\",\n  \"oklch(0.7 0.18 270)\",\n];\n\n/**\n * Compare a preset to the current color in canonical OKLCH form so the\n * active ring shows regardless of the active output format (a hex-string\n * comparison would never match when the format is anything but `hex`).\n * Achromatic colors have an undefined hue; the hue check is skipped when\n * either side has near-zero chroma so swatches like `oklch(0.5 0 0)` match\n * the current gray regardless of its drifted hue.\n */\nexport function isSameSwatchColor(\n  preset: OklchColor,\n  color: OklchColor,\n): boolean {\n  if (Math.abs(preset.l - color.l) >= 1e-3) return false;\n  if (Math.abs(preset.c - color.c) >= 1e-3) return false;\n  if (Math.abs(preset.alpha - color.alpha) >= 1e-3) return false;\n  if (preset.c < 1e-3 || color.c < 1e-3) return true;\n  const d = (((preset.h - color.h) % 360) + 360) % 360;\n  const wrapped = d > 180 ? 360 - d : d;\n  return wrapped < 0.1;\n}\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/hooks/use-color-picker.ts",
      "type": "registry:hook",
      "target": "@ui/fill-picker/hooks/use-color-picker.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  parseColor,\n  parseColorDetailed,\n  formatAll,\n  gamutFromFormat,\n  gamutInfo,\n  contrast,\n  toGamut,\n} from \"../lib/color\";\nimport type {\n  ColorFormat,\n  ContrastResult,\n  GamutInfo,\n  OklchColor,\n} from \"../lib/types\";\n\nexport type ColorComponent = \"l\" | \"c\" | \"h\" | \"alpha\";\n\nexport interface UseColorPickerProps {\n  /** Controlled color value (string or canonical OklchColor). */\n  value?: string | OklchColor;\n  /** Initial color when uncontrolled. */\n  defaultValue?: string | OklchColor;\n  /**\n   * Fires whenever the color changes.\n   *  - `color`: canonical OKLCH form (lossless source of truth).\n   *  - `formatted`: the active output format string (governed by `format`).\n   *  - `formats`: every supported format pre-serialized — use `formats.hex`\n   *    when you need a fallback alongside the canonical `formats.oklch`.\n   */\n  onValueChange?: (\n    color: OklchColor,\n    formatted: string,\n    formats: Record<ColorFormat, string>,\n  ) => void;\n  /** Active output format. */\n  format?: ColorFormat;\n  /** Initial format when uncontrolled. */\n  defaultFormat?: ColorFormat;\n  onFormatChange?: (format: ColorFormat) => void;\n  /**\n   * Color spaces (output formats) the picker exposes. Restricts the\n   * FormatSwitcher tabs and the default format. Defaults to all formats.\n   */\n  formats?: ColorFormat[];\n  /** Background used for contrast metrics. */\n  backgroundColor?: string | OklchColor;\n}\n\nexport interface ColorPickerState {\n  color: OklchColor;\n  format: ColorFormat;\n  /** Active format string (mirrors `format`). */\n  formatted: string;\n  /** Allowed output formats (the set restricting the FormatSwitcher). */\n  formats: ColorFormat[];\n  /** Pre-serialized strings for every supported format. */\n  formatStrings: Record<ColorFormat, string>;\n  setColor: (next: string | OklchColor) => void;\n  setComponent: (key: ColorComponent, value: number) => void;\n  adjustComponent: (key: ColorComponent, delta: number) => void;\n  setFormat: (f: ColorFormat) => void;\n  setFromString: (s: string) => boolean;\n  gamut: GamutInfo;\n  contrast: ContrastResult;\n  background: OklchColor;\n}\n\nconst ALL_FORMATS: ColorFormat[] = [\"hex\", \"rgb\", \"hsl\", \"hsb\", \"oklch\", \"oklab\", \"p3\"];\n\nconst BLACK: OklchColor = { l: 0, c: 0, h: 0, alpha: 1 };\nconst WHITE: OklchColor = { l: 1, c: 0, h: 0, alpha: 1 };\n\nfunction coerce(input: string | OklchColor | undefined, fallback: OklchColor): OklchColor {\n  if (!input) return fallback;\n  if (typeof input === \"string\") {\n    return parseColor(input) ?? fallback;\n  }\n  // Strings are hue-wrapped by the parser; normalize objects the same way.\n  return { ...input, h: wrapHue(input.h) };\n}\n\nfunction clamp(x: number, lo: number, hi: number) {\n  return Math.min(Math.max(x, lo), hi);\n}\n\nfunction wrapHue(h: number) {\n  const m = h % 360;\n  return m < 0 ? m + 360 : m;\n}\n\nconst HUE_EPS = 1e-4;\nfunction isAchromatic(c: OklchColor): boolean {\n  return c.c <= HUE_EPS || c.l <= HUE_EPS || c.l >= 1 - HUE_EPS;\n}\n\nfunction applyComponent(c: OklchColor, key: ColorComponent, raw: number): OklchColor {\n  // NaN/Infinity (e.g. a cleared number field) would poison every derived\n  // conversion; ignore the write instead.\n  if (!Number.isFinite(raw)) return c;\n  switch (key) {\n    case \"l\":\n      return { ...c, l: clamp(raw, 0, 1) };\n    case \"c\":\n      return { ...c, c: Math.max(raw, 0) };\n    case \"h\":\n      return { ...c, h: wrapHue(raw) };\n    case \"alpha\":\n      return { ...c, alpha: clamp(raw, 0, 1) };\n  }\n}\n\nexport function useColorPicker(props: UseColorPickerProps = {}): ColorPickerState {\n  const {\n    value: controlledValue,\n    defaultValue,\n    onValueChange,\n    format: controlledFormat,\n    defaultFormat = \"p3\",\n    onFormatChange,\n    formats: formatsProp,\n    backgroundColor,\n  } = props;\n\n  const formats = React.useMemo<ColorFormat[]>(\n    () => (formatsProp && formatsProp.length > 0 ? formatsProp : ALL_FORMATS),\n    [formatsProp],\n  );\n  const initialFormat = formats.includes(defaultFormat) ? defaultFormat : formats[0];\n\n  const [internalColor, setInternalColor] = React.useState<OklchColor>(() =>\n    coerce(defaultValue, BLACK),\n  );\n  const [internalFormat, setInternalFormat] = React.useState<ColorFormat>(initialFormat);\n\n  const isControlledColor = controlledValue !== undefined;\n  const isControlledFormat = controlledFormat !== undefined;\n\n  // Hue is undefined for achromatic colors (c=0, pure black, pure white) so\n  // any string round-trip through hex/rgb erases it. Remember the last hue\n  // observed on a chromatic, mid-lightness color and substitute it back when\n  // the resolved color lands on an achromatic edge — keeps the area picker\n  // from snapping the hue to 0 when the user drags toward gray/black/white.\n  // Seeded from the initial color state rather than re-parsing\n  // `defaultValue` on every render (every drag frame).\n  const lastGoodHueRef = React.useRef<number>(internalColor.h || 0);\n\n  const isControlledStringInput =\n    isControlledColor && typeof controlledValue === \"string\";\n  const controlledParsed = React.useMemo(\n    () =>\n      isControlledStringInput\n        ? parseColorDetailed(controlledValue as string)\n        : null,\n    [controlledValue, isControlledStringInput],\n  );\n  const rawColor = React.useMemo(\n    () =>\n      isControlledStringInput\n        ? (controlledParsed?.color ?? BLACK)\n        : isControlledColor\n          ? coerce(controlledValue, BLACK)\n          : internalColor,\n    [\n      controlledParsed,\n      controlledValue,\n      internalColor,\n      isControlledColor,\n      isControlledStringInput,\n    ],\n  );\n  // A hue is \"good\" (worth remembering) when it's meaningful: any chromatic\n  // color, or a string that authored a hue even at zero chroma (OKLCH can).\n  const controlledHueAuthored = controlledParsed\n    ? !controlledParsed.hueMissing\n    : false;\n  React.useLayoutEffect(() => {\n    if (!isAchromatic(rawColor) || controlledHueAuthored) {\n      lastGoodHueRef.current = rawColor.h;\n    }\n  }, [controlledHueAuthored, rawColor]);\n  // Only substitute on string-controlled inputs whose parse actually lost\n  // the hue (achromatic hex/rgb/hsl round-trips). Strings that author a hue\n  // (e.g. `oklch(0.5 0 90)`), object-controlled, and uncontrolled state all\n  // carry the hue verbatim — including explicit user assignments like\n  // setComponent(\"h\", 0) on a black/white color.\n  // Reading the prior committed hue is intentional: layout effects keep the\n  // ref current before input can arrive, while avoiding a render-time write.\n  // eslint-disable-next-line react-hooks/refs\n  const preservedHue = lastGoodHueRef.current;\n  const color = React.useMemo<OklchColor>(\n    () =>\n      isControlledStringInput && (controlledParsed?.hueMissing ?? true)\n        ? { ...rawColor, h: preservedHue }\n        : rawColor,\n    [\n      controlledParsed?.hueMissing,\n      isControlledStringInput,\n      preservedHue,\n      rawColor,\n    ],\n  );\n  const format = isControlledFormat ? controlledFormat! : internalFormat;\n  const background = React.useMemo(\n    () => coerce(backgroundColor, WHITE),\n    [backgroundColor],\n  );\n\n  const formatStrings = React.useMemo(() => formatAll(color), [color]);\n  const formatted = formatStrings[format];\n  const gamut = React.useMemo(() => gamutInfo(color), [color]);\n  const contrastResult = React.useMemo(\n    () => contrast(color, background),\n    [color, background],\n  );\n\n  const commitColor = React.useCallback(\n    (next: OklchColor, formatOverride?: ColorFormat) => {\n      // NaN never equals itself, so storing it would spin the render-time\n      // sync in consumers; drop non-finite colors at the boundary.\n      if (![next.l, next.c, next.h, next.alpha].every(Number.isFinite)) return;\n      if (!isControlledColor) setInternalColor(next);\n      if (onValueChange) {\n        const all = formatAll(next);\n        onValueChange(next, all[formatOverride ?? format], all);\n      }\n    },\n    [format, isControlledColor, onValueChange],\n  );\n\n  // Commit a string input, re-pinning the remembered hue when the parse\n  // lost it (achromatic hex/rgb/hsl) — otherwise a gray commit would store\n  // the defaulted h: 0 and later re-saturation would snap to red.\n  const commitString = React.useCallback(\n    (s: string): boolean => {\n      const parsed = parseColorDetailed(s);\n      if (!parsed) return false;\n      const next = parsed.hueMissing\n        ? { ...parsed.color, h: lastGoodHueRef.current }\n        : parsed.color;\n      commitColor(next);\n      return true;\n    },\n    [commitColor],\n  );\n\n  const setColor = React.useCallback(\n    (next: string | OklchColor) => {\n      if (typeof next === \"string\") {\n        commitString(next);\n        return;\n      }\n      commitColor(next);\n    },\n    [commitColor, commitString],\n  );\n\n  const setComponent = React.useCallback(\n    (key: ColorComponent, val: number) => {\n      commitColor(applyComponent(color, key, val));\n    },\n    [color, commitColor],\n  );\n\n  const adjustComponent = React.useCallback(\n    (key: ColorComponent, delta: number) => {\n      const current =\n        key === \"l\" ? color.l : key === \"c\" ? color.c : key === \"h\" ? color.h : color.alpha;\n      commitColor(applyComponent(color, key, current + delta));\n    },\n    [color, commitColor],\n  );\n\n  const setFormat = React.useCallback(\n    (f: ColorFormat) => {\n      // Switching formats is also a switch of *picking* gamut. If the current\n      // OKLCH state lives outside the new format's gamut (e.g. user authored\n      // a wide P3 chroma in OKLCH mode, then toggled to hex), the displayed\n      // string would be gamut-mapped at format time but the underlying state\n      // — and the gamut badge — would still report out-of-gamut. Clamp on\n      // the way in so state and display agree. Hue is pinned per the picker's\n      // \"chroma is the only lossy axis\" invariant.\n      const targetGamut = gamutFromFormat(f);\n      const info = gamutInfo(color);\n      const alreadyIn =\n        targetGamut === \"srgb\"\n          ? info.inSrgb\n          : targetGamut === \"p3\"\n            ? info.inP3\n            : info.inRec2020;\n      if (!alreadyIn) {\n        const targetHue = color.h;\n        const clamped = toGamut(color, targetGamut);\n        // Pass the next format explicitly so the clamp notification never\n        // emits using the closure's previous format.\n        commitColor({ ...clamped, h: targetHue }, f);\n      }\n      if (!isControlledFormat) setInternalFormat(f);\n      onFormatChange?.(f);\n    },\n    [color, commitColor, isControlledFormat, onFormatChange],\n  );\n\n  const setFromString = React.useCallback(\n    (s: string) => commitString(s),\n    [commitString],\n  );\n\n  return {\n    color,\n    format,\n    formatted,\n    formats,\n    formatStrings,\n    setColor,\n    setComponent,\n    adjustComponent,\n    setFormat,\n    setFromString,\n    gamut,\n    contrast: contrastResult,\n    background,\n  };\n}\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/context.tsx",
      "type": "registry:ui",
      "target": "@ui/fill-picker/context.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type { ColorPickerState } from \"./hooks/use-color-picker\";\n\nexport const ColorPickerContext = React.createContext<ColorPickerState | null>(null);\n\nexport function useColorPickerContext(): ColorPickerState {\n  const ctx = React.useContext(ColorPickerContext);\n  if (!ctx) {\n    throw new Error(\n      \"ColorPicker.* parts must be rendered inside <ColorPicker> (or <ColorPicker.Root>)\",\n    );\n  }\n  return ctx;\n}\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/root.tsx",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/root.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ColorPickerContext } from \"../context\";\nimport { useColorPicker, type UseColorPickerProps } from \"../hooks/use-color-picker\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface RootProps\n  extends UseColorPickerProps,\n    Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  /** When true the picker is rendered without children using the default layout. */\n  asChild?: boolean;\n}\n\nexport const Root = React.forwardRef<HTMLDivElement, RootProps>(function Root(\n  {\n    value,\n    defaultValue,\n    onValueChange,\n    format,\n    defaultFormat,\n    onFormatChange,\n    formats,\n    backgroundColor,\n    className,\n    children,\n    ...rest\n  },\n  ref,\n) {\n  const state = useColorPicker({\n    value,\n    defaultValue,\n    onValueChange,\n    format,\n    defaultFormat,\n    onFormatChange,\n    formats,\n    backgroundColor,\n  });\n\n  return (\n    <ColorPickerContext.Provider value={state}>\n      <div\n        ref={ref}\n        data-slot=\"color-picker\"\n        className={cn(\n          \"flex w-full max-w-[280px] flex-col gap-2 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-sm\",\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </div>\n    </ColorPickerContext.Provider>\n  );\n});\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/field.tsx",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/field.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Bordered, h-8 shell every multi-field input inside the picker shares.\n * Visual source of truth for `<ColorPicker.ChannelInput>`, the gradient\n * angle / position / radius / ellipse-radii / stop-position inputs, and\n * both CSS-string inputs. Owning the shell here means a single edit\n * here re-themes every text input in the package.\n */\nexport const FieldShell = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(function FieldShell({ className, ...rest }, ref) {\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        \"flex h-8 items-stretch overflow-hidden rounded-md border border-input bg-transparent font-mono text-xs shadow-xs\",\n        \"focus-within:ring-1 focus-within:ring-ring\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n});\n\n/** Vertical 1px divider between fields inside a `FieldShell`. */\nexport function FieldDivider() {\n  return <div aria-hidden className=\"w-px self-stretch bg-border\" />;\n}\n\n/**\n * Borderless input that lives inside a `FieldShell`. Defaults match the\n * channel-input numeric field: full width of its slot, right-aligned\n * tabular digits, transparent background. Pass `className` to override\n * per use (e.g. left-align for hex / CSS strings).\n *\n * When `nudge` is set, ↑/↓ step the numeric value by `nudge` (Shift × 10).\n * Implemented by dispatching a native input event so the consumer's\n * existing `onChange` handler runs unchanged — no parallel commit path,\n * no double validation. Non-numeric values are ignored.\n */\nexport interface FieldInputProps extends React.InputHTMLAttributes<HTMLInputElement> {\n  /**\n   * Step amount for ↑/↓ keyboard nudge. Shift multiplies by 10. Omit\n   * to disable nudge (text fields and non-numeric inputs).\n   */\n  nudge?: number;\n}\n\nexport const FieldInput = React.forwardRef<HTMLInputElement, FieldInputProps>(\n  function FieldInput({ className, type = \"text\", nudge, onKeyDown, ...rest }, ref) {\n    return (\n      <input\n        ref={ref}\n        type={type}\n        spellCheck={false}\n        autoComplete=\"off\"\n        autoCorrect=\"off\"\n        autoCapitalize=\"off\"\n        onKeyDown={(e) => {\n          if (nudge && (e.key === \"ArrowUp\" || e.key === \"ArrowDown\")) {\n            e.preventDefault();\n            // Treat empty / unparseable input (e.g. RadiusInput in its\n            // \"auto\" placeholder state) as 0 so the first arrow press\n            // commits a real numeric value instead of doing nothing.\n            const parsed = parseFloat(e.currentTarget.value);\n            const cur = Number.isFinite(parsed) ? parsed : 0;\n            const delta =\n              (e.key === \"ArrowUp\" ? 1 : -1) * (e.shiftKey ? nudge * 10 : nudge);\n            const next = cur + delta;\n            // Use the native value setter so React's synthetic onChange\n            // fires — directly assigning `.value` is swallowed by React's\n            // controlled-input tracker.\n            const setter = Object.getOwnPropertyDescriptor(\n              window.HTMLInputElement.prototype,\n              \"value\",\n            )?.set;\n            setter?.call(e.currentTarget, String(next));\n            e.currentTarget.dispatchEvent(new Event(\"input\", { bubbles: true }));\n          }\n          onKeyDown?.(e);\n        }}\n        className={cn(\n          \"w-full min-w-0 bg-transparent px-1.5 text-right outline-none tabular-nums\",\n          className,\n        )}\n        {...rest}\n      />\n    );\n  },\n);\n\nexport interface FieldDraftInputProps\n  extends Omit<FieldInputProps, \"value\" | \"defaultValue\" | \"onChange\"> {\n  /** Canonical display string; re-seeds the draft whenever it changes. */\n  value: string;\n  /**\n   * Called with the raw draft on Enter, on blur after an edit, and on each\n   * ↑/↓ nudge. The consumer parses, clamps and writes; an unparseable or\n   * clamped commit just snaps the draft back to `value`.\n   */\n  onCommit: (raw: string) => void;\n}\n\n/**\n * `FieldInput` with a local draft: keystrokes only edit the text, and the\n * value is committed on Enter / blur (Escape reverts). Committing on every\n * keystroke makes multi-digit and decimal entry impossible when the parsed\n * prefix gets clamped or normalized (\"4\" of \"400\" wraps, \"\" can't be typed).\n * ↑/↓ nudges (via `nudge`) still commit immediately.\n */\nexport const FieldDraftInput = React.forwardRef<\n  HTMLInputElement,\n  FieldDraftInputProps\n>(function FieldDraftInput(\n  { value, onCommit, onBlur, onKeyDown, nudge, ...rest },\n  ref,\n) {\n  const [draft, setDraft] = React.useState(value);\n  const [dirty, setDirty] = React.useState(false);\n  // Same in-render re-seed as `ChannelField` / `HexField`.\n  const [prevValue, setPrevValue] = React.useState(value);\n  if (value !== prevValue) {\n    setPrevValue(value);\n    setDraft(value);\n    setDirty(false);\n  }\n\n  const commit = (raw: string) => {\n    onCommit(raw);\n    // Snap back to the canonical string. If the commit changed it, the\n    // re-seed above overwrites this on the next render anyway; if it didn't\n    // (clamped to the same value, unparseable), this is what clears \"999\".\n    setDraft(value);\n    setDirty(false);\n  };\n\n  return (\n    <FieldInput\n      ref={ref}\n      nudge={nudge}\n      value={draft}\n      onChange={(e) => {\n        setDraft(e.target.value);\n        setDirty(true);\n      }}\n      onBlur={(e) => {\n        if (dirty) commit(e.currentTarget.value);\n        onBlur?.(e);\n      }}\n      onKeyDown={(e) => {\n        if (e.key === \"Enter\") {\n          e.preventDefault();\n          if (dirty) commit(e.currentTarget.value);\n        } else if (e.key === \"Escape\") {\n          setDraft(value);\n          setDirty(false);\n        } else if (nudge && (e.key === \"ArrowUp\" || e.key === \"ArrowDown\")) {\n          // FieldInput already wrote the nudged number into the input.\n          commit(e.currentTarget.value);\n        }\n        onKeyDown?.(e);\n      }}\n      {...rest}\n    />\n  );\n});\n\n/**\n * Flex slot that pairs a `FieldInput` with an optional `FieldSuffix`\n * (the muted `°`, `%`, `px`, `×` glyph). Renders as a `<label>` so the\n * suffix is part of the clickable hit area but doesn't steal focus.\n */\nexport const FieldInputGroup = React.forwardRef<\n  HTMLLabelElement,\n  React.LabelHTMLAttributes<HTMLLabelElement>\n>(function FieldInputGroup({ className, ...rest }, ref) {\n  return (\n    <label\n      ref={ref}\n      className={cn(\n        \"relative inline-flex h-full min-w-0 flex-1 items-center justify-end\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n});\n\n/** Muted, non-interactive suffix label (°, %, px, ×). */\nexport const FieldSuffix = React.forwardRef<\n  HTMLSpanElement,\n  React.HTMLAttributes<HTMLSpanElement>\n>(function FieldSuffix({ className, ...rest }, ref) {\n  return (\n    <span\n      ref={ref}\n      aria-hidden\n      className={cn(\n        \"pointer-events-none pr-1.5 text-muted-foreground\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n});\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/area.tsx",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/area.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useColorPickerContext } from \"../context\";\nimport {\n  findCusp,\n  findMaxChroma,\n  formatColor,\n  gamutFromFormat as libGamutFromFormat,\n  gamutSignedDistance,\n  linSrgbToLinP3,\n  oklchToLinearSrgb,\n  toGamut,\n} from \"../lib/color\";\nimport type { Gamut, OklchColor } from \"../lib/types\";\nimport { useLiveAnnounce } from \"./use-live-announce\";\nimport { cn } from \"@/lib/utils\";\n\nexport type AreaMode = \"oklch-cl\" | \"hsv-sv\" | \"oklch-hc\";\nexport type AreaGamut = Gamut | \"none\";\n\nexport interface AreaProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * What the 2D Area represents.\n   *  - oklch-cl: Y = OKLCH lightness, X = chroma normalized to gamut. Perceptually uniform.\n   *    Top row is white-ish (max chroma at L=1 is 0). Most-saturated colors live at mid-Y.\n   *  - hsv-sv: Y = HSV-style \"value\", X = saturation. OKHSV-like — anchored to the gamut\n   *    cusp so top-right corner is fully saturated (Photoshop/Framer feel).\n   *  - oklch-hc: X = hue, Y = chroma normalized (lightness picked separately).\n   */\n  mode?: AreaMode;\n  /**\n   * Ignored when `gamut` is \"srgb\" / \"p3\" / \"rec2020\" — chroma fills the\n   * square up to the gamut surface. Only meaningful when `gamut === \"none\"`,\n   * where it bounds the absolute-chroma X axis.\n   */\n  chromaMax?: number;\n  /**\n   * Render gamut. The square is filled with in-gamut colors up to this\n   * gamut's surface, and warning lines mark narrower-gamut cutoffs inside.\n   * Defaults to the gamut implied by the active output format\n   * (hex/rgb/hsl/hsb → srgb, p3 → p3, oklch/oklab → rec2020).\n   * Pass \"none\" to disable warping and paint the raw OKLCH plane up to\n   * `chromaMax` (no fill warp, no lines).\n   */\n  gamut?: AreaGamut;\n  /**\n   * Show the gamut-cutoff warning lines drawn inside the active render gamut.\n   * Defaults to true. Setting to false hides them regardless of `gamut` —\n   * useful for a quieter visual when the badge already conveys gamut status.\n   * No effect when `gamut` is \"srgb\" or \"none\" (nothing to draw anyway).\n   */\n  showWarningLines?: boolean;\n  /** Render resolution of the gradient canvas. Higher = sharper, slower. Default 160. */\n  resolution?: number;\n  /**\n   * Soft-proof out-of-display colors instead of per-channel clipping them.\n   * When the active render gamut exceeds the display's (e.g. `gamut=\"rec2020\"`\n   * on a P3 monitor), pixels past the display gamut are chroma-reduced in\n   * OKLCH to the display surface — preserving hue and lightness, flattening\n   * chroma. The default (false) preserves the legacy per-channel clip, which\n   * is cheaper but introduces hue shifts and posterization in unrenderable\n   * regions. Off by default to keep first-paint cost identical to before.\n   */\n  softProof?: boolean;\n}\n\n/** Which gamuts get a warning line drawn inside the active render gamut. */\nfunction warningGamuts(active: AreaGamut): Gamut[] {\n  switch (active) {\n    case \"srgb\":\n    case \"none\":\n      return [];\n    case \"p3\":\n      return [\"srgb\"];\n    case \"rec2020\":\n      return [\"srgb\", \"p3\"];\n  }\n}\n\n/**\n * Read display-P3 support fresh on the client and re-react if the user moves\n * the window to a different display. Module-load evaluation locks the value\n * to `false` in Next.js's SSR module cache (window is undefined there), and\n * `useState(() => ...)` would diverge between server and client renders.\n * `useSyncExternalStore` returns `false` on the server (matching SSR) and the\n * real value on the client during reconciliation — no hydration mismatch.\n */\nconst subscribeP3 =\n  typeof window !== \"undefined\" && typeof window.matchMedia === \"function\"\n    ? (onChange: () => void) => {\n        const mq = window.matchMedia(\"(color-gamut: p3)\");\n        mq.addEventListener(\"change\", onChange);\n        return () => mq.removeEventListener(\"change\", onChange);\n      }\n    : () => () => {};\nconst getP3Client = () =>\n  typeof window !== \"undefined\" &&\n  typeof window.matchMedia === \"function\" &&\n  window.matchMedia(\"(color-gamut: p3)\").matches;\nconst getP3Server = () => false;\n\nexport const Area = React.forwardRef<HTMLDivElement, AreaProps>(function Area(\n  {\n    mode = \"oklch-cl\",\n    chromaMax = 0.4,\n    gamut: gamutProp,\n    showWarningLines = true,\n    resolution = 160,\n    softProof = false,\n    className,\n    ...rest\n  },\n  ref,\n) {\n  const { color, setColor, format } = useColorPickerContext();\n  const supportsP3 = React.useSyncExternalStore(\n    subscribeP3,\n    getP3Client,\n    getP3Server,\n  );\n  const gamut: AreaGamut = gamutProp ?? libGamutFromFormat(format);\n  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n  const containerRef = React.useRef<HTMLDivElement | null>(null);\n  const [paths, setPaths] = React.useState<string[][]>([]);\n  // Bead position override. The (X, Y) → OKLCH mapping is lossy at the\n  // gamut poles: at l=0 or l=1 every X collapses to chroma 0, so deriving\n  // the bead back from the resulting color always recovers X=0 — making\n  // the bead jump to the left edge when the pointer drags past the top or\n  // bottom. We keep the user's last picked (xn, yn) and render the bead\n  // there. Cleared whenever color changes from a source other than our\n  // own moveTo (e.g. swatch click, controlled value, input typing).\n  const [pickPos, setPickPos] = React.useState<[number, number] | null>(null);\n  const selfSetRef = React.useRef(false);\n\n  React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);\n\n  // Adjusting state during rendering — React-blessed alternative to\n  // syncing color → pickPos in a useEffect. We compare channel values\n  // (not the OKLCH object identity, which a controlled ColorPicker may\n  // re-create each render even when values are unchanged) against the\n  // previous render's snapshot. When the user-driven path moved the\n  // bead, `selfSetRef` short-circuits the clear so the override sticks.\n  const [prevColor, setPrevColor] = React.useState(color);\n  if (\n    prevColor.l !== color.l ||\n    prevColor.c !== color.c ||\n    prevColor.h !== color.h ||\n    prevColor.alpha !== color.alpha\n  ) {\n    setPrevColor(color);\n    if (!selfSetRef.current && pickPos !== null) setPickPos(null);\n  }\n  // Disarm after every commit rather than on the echo: a pick that leaves\n  // the color unchanged (e.g. any X at the white pole) never produces an\n  // echo, and a still-armed flag would swallow the next *external* change.\n  // Our own pick's color update lands in the same render as `setPickPos`.\n  React.useEffect(() => {\n    selfSetRef.current = false;\n  });\n\n  // The gradient and warning lines depend only on the axis the mode keeps\n  // *fixed* (hue for oklch-cl/hsv-sv, lightness for oklch-hc). Depending on\n  // every channel of `color` would trigger a 25 600-pixel canvas repaint plus\n  // a 128² marching-squares pass on every pointer tick — enough to stall the\n  // bead. Narrowing the dep to the locked axis keeps drags free.\n  const fixedAxisValue = mode === \"oklch-hc\" ? color.l : color.h;\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const w = resolution;\n    const h = resolution;\n    canvas.width = w;\n    canvas.height = h;\n    const ctx2dOpts: CanvasRenderingContext2DSettings = supportsP3\n      ? { colorSpace: \"display-p3\" }\n      : {};\n    const ctx = canvas.getContext(\"2d\", ctx2dOpts);\n    if (!ctx) return;\n    const img = ctx.createImageData(w, h);\n    paintGradient(img, w, h, mode, color, chromaMax, gamut, supportsP3, softProof);\n    ctx.putImageData(img, 0, 0);\n    if (gamut === \"none\" || !showWarningLines) {\n      setPaths([]);\n    } else {\n      setPaths(\n        warningGamuts(gamut).map((g) =>\n          computeGamutPaths(mode, color, chromaMax, g, gamut),\n        ),\n      );\n    }\n    // `color` is intentionally omitted — gradient + warning lines depend only\n    // on the locked axis (`fixedAxisValue`), not on every channel of `color`.\n    // Repainting on every pointer tick would stall the bead.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [mode, fixedAxisValue, chromaMax, gamut, showWarningLines, resolution, softProof, supportsP3]);\n\n  const [derivedPx, derivedPy] = positionFor(mode, color, chromaMax, gamut);\n  const [px, py] = pickPos ?? [derivedPx, derivedPy];\n\n  // role=\"application\" carries no value semantics, so keyboard adjustments\n  // would otherwise be silent to screen readers. Announce the value text in a\n  // polite live region, debounced so held-down arrows don't flood the queue.\n  const [liveText, announce] = useLiveAnnounce();\n\n  const moveTo = React.useCallback(\n    (x: number, y: number) => {\n      const xn = clamp01(x);\n      const yn = clamp01(y);\n      const next = sampleAt(mode, color, chromaMax, gamut, xn, yn);\n      setPickPos([xn, yn]);\n      selfSetRef.current = true;\n      // With warping every (X, Y) is in-gamut by construction, so toGamut is\n      // defensive only — guards against drift at numerical boundaries. When\n      // gamut === \"none\" the user has explicitly opted out of warping and we\n      // skip clamping entirely (raw OKLCH plane).\n      let committed: OklchColor;\n      if (gamut !== \"none\") {\n        const targetHue = next.h;\n        const clamped = toGamut(next, gamut as Gamut);\n        committed = { ...clamped, h: targetHue };\n      } else {\n        committed = next;\n      }\n      setColor(committed);\n      announce(ariaValueTextFor(mode, committed, chromaMax, gamut));\n    },\n    [mode, chromaMax, color, setColor, gamut, announce],\n  );\n\n  const handlePointer = React.useCallback(\n    (clientX: number, clientY: number) => {\n      const el = containerRef.current;\n      if (!el) return;\n      const rect = el.getBoundingClientRect();\n      // A collapsed / animating-out container has no pickable surface.\n      if (rect.width <= 0 || rect.height <= 0) return;\n      moveTo((clientX - rect.left) / rect.width, (clientY - rect.top) / rect.height);\n    },\n    [moveTo],\n  );\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);\n    handlePointer(e.clientX, e.clientY);\n  };\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.buttons !== 1) return;\n    handlePointer(e.clientX, e.clientY);\n  };\n  const releaseCapture = (e: React.PointerEvent<HTMLDivElement>) => {\n    const el = e.currentTarget as HTMLDivElement;\n    if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const big = e.shiftKey ? 10 : 1;\n    const stepX = big / 100; // % of axis\n    const stepY = big / 100;\n    let nx = px;\n    let ny = py;\n    switch (e.key) {\n      case \"ArrowLeft\":\n        nx -= stepX;\n        break;\n      case \"ArrowRight\":\n        nx += stepX;\n        break;\n      case \"ArrowUp\":\n        ny -= stepY;\n        break;\n      case \"ArrowDown\":\n        ny += stepY;\n        break;\n      case \"Home\":\n        nx = 0;\n        break;\n      case \"End\":\n        nx = 1;\n        break;\n      case \"PageUp\":\n        ny -= 0.1;\n        break;\n      case \"PageDown\":\n        ny += 0.1;\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    moveTo(nx, ny);\n  };\n\n  const valueText = ariaValueTextFor(mode, color, chromaMax, gamut);\n\n  return (\n    <div\n      ref={containerRef}\n      data-slot=\"color-picker-area\"\n      role=\"application\"\n      aria-label={`Color area: ${valueText}`}\n      aria-roledescription=\"2D color area, use arrow keys to adjust\"\n      tabIndex={0}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={releaseCapture}\n      onPointerCancel={releaseCapture}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"relative h-45 w-full cursor-crosshair overflow-hidden rounded-md border border-border outline-none touch-none select-none\",\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover\",\n        className,\n      )}\n      {...rest}\n    >\n      <canvas\n        ref={canvasRef}\n        className=\"block h-full w-full\"\n        aria-hidden=\"true\"\n      />\n      {paths.length > 0 && (\n        <svg\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 h-full w-full\"\n          viewBox=\"0 0 1 1\"\n          preserveAspectRatio=\"none\"\n        >\n          {paths.map((groupPaths, gi) =>\n            groupPaths.map((d, i) => (\n              <g key={`${gi}-${i}`}>\n                <path\n                  d={d}\n                  fill=\"none\"\n                  stroke=\"rgba(0,0,0,0.55)\"\n                  strokeWidth={3}\n                  strokeLinejoin=\"round\"\n                  strokeLinecap=\"round\"\n                  vectorEffect=\"non-scaling-stroke\"\n                />\n                <path\n                  d={d}\n                  fill=\"none\"\n                  stroke=\"rgba(255,255,255,0.6)\"\n                  strokeWidth={1}\n                  strokeLinejoin=\"round\"\n                  strokeLinecap=\"round\"\n                  vectorEffect=\"non-scaling-stroke\"\n                />\n              </g>\n            )),\n          )}\n        </svg>\n      )}\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {liveText}\n      </span>\n      <div\n        className=\"pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.6)]\"\n        style={{\n          left: `${px * 100}%`,\n          top: `${py * 100}%`,\n          background: formatColor({ ...color, alpha: 1 }, \"oklch\"),\n        }}\n      />\n    </div>\n  );\n});\n\nfunction clamp01(x: number) {\n  return x < 0 ? 0 : x > 1 ? 1 : x;\n}\n\nfunction positionFor(\n  mode: AreaMode,\n  c: OklchColor,\n  chromaMax: number,\n  gamut: AreaGamut,\n): [number, number] {\n  if (gamut === \"none\") {\n    switch (mode) {\n      case \"oklch-cl\":\n        return [clamp01(c.c / chromaMax), clamp01(1 - c.l)];\n      case \"hsv-sv\": {\n        const v = c.l;\n        const s = v > 0 ? c.c / Math.max(v * chromaMax, 1e-6) : 0;\n        return [clamp01(s), clamp01(1 - v)];\n      }\n      case \"oklch-hc\":\n        return [\n          clamp01((((c.h % 360) + 360) % 360) / 360),\n          clamp01(1 - c.c / chromaMax),\n        ];\n    }\n  }\n  switch (mode) {\n    case \"oklch-cl\": {\n      const maxC = findMaxChroma(c.l, c.h, gamut as Gamut);\n      const safeMaxC = maxC > 1e-6 ? maxC : 1e-6;\n      return [clamp01(c.c / safeMaxC), clamp01(1 - c.l)];\n    }\n    case \"hsv-sv\": {\n      const cusp = findCusp(c.h, gamut as Gamut);\n      // L = V·(1 − S·(1 − Lc)), C = V·S·Cc → solve for V, S.\n      const V = clamp01(c.l + (c.c * (1 - cusp.l)) / Math.max(cusp.c, 1e-6));\n      const S = V > 1e-6 ? clamp01(c.c / (V * Math.max(cusp.c, 1e-6))) : 0;\n      return [S, clamp01(1 - V)];\n    }\n    case \"oklch-hc\": {\n      const maxC = findMaxChroma(c.l, c.h, gamut as Gamut);\n      const safeMaxC = maxC > 1e-6 ? maxC : 1e-6;\n      return [\n        clamp01((((c.h % 360) + 360) % 360) / 360),\n        clamp01(1 - c.c / safeMaxC),\n      ];\n    }\n  }\n}\n\n/**\n * Build the OKLCH color for a normalized (x, y) inside the area, given the\n * locked axis on `base`. Mirror of `positionFor`.\n */\nfunction sampleAt(\n  mode: AreaMode,\n  base: OklchColor,\n  chromaMax: number,\n  gamut: AreaGamut,\n  xn: number,\n  yn: number,\n): OklchColor {\n  if (gamut === \"none\") {\n    switch (mode) {\n      case \"oklch-cl\":\n        return { ...base, c: xn * chromaMax, l: 1 - yn };\n      case \"hsv-sv\": {\n        const v = 1 - yn;\n        return { ...base, l: v, c: xn * v * chromaMax };\n      }\n      case \"oklch-hc\":\n        return { ...base, h: xn * 360, c: (1 - yn) * chromaMax };\n    }\n  }\n  switch (mode) {\n    case \"oklch-cl\": {\n      const l = 1 - yn;\n      const maxC = findMaxChroma(l, base.h, gamut as Gamut);\n      return { ...base, l, c: xn * maxC };\n    }\n    case \"hsv-sv\": {\n      const cusp = findCusp(base.h, gamut as Gamut);\n      const V = 1 - yn;\n      const S = xn;\n      const l = V * (1 - S * (1 - cusp.l));\n      const c = V * S * cusp.c;\n      return { ...base, l, c };\n    }\n    case \"oklch-hc\": {\n      const h = xn * 360;\n      const maxC = findMaxChroma(base.l, h, gamut as Gamut);\n      return { ...base, h, c: (1 - yn) * maxC };\n    }\n  }\n}\n\nfunction ariaValueTextFor(\n  mode: AreaMode,\n  c: OklchColor,\n  chromaMax: number,\n  gamut: AreaGamut,\n): string {\n  switch (mode) {\n    case \"oklch-cl\": {\n      const maxC =\n        gamut === \"none\"\n          ? chromaMax\n          : findMaxChroma(c.l, c.h, gamut as Gamut) || chromaMax;\n      return `Lightness ${(c.l * 100).toFixed(0)} percent, chroma ${c.c.toFixed(2)} of ${maxC.toFixed(2)}, hue ${c.h.toFixed(0)} degrees`;\n    }\n    case \"hsv-sv\": {\n      if (gamut === \"none\") {\n        const v = c.l;\n        const s = (c.c / Math.max(v * chromaMax, 1e-6)) * 100 || 0;\n        return `Saturation ${s.toFixed(0)} percent, value ${(v * 100).toFixed(0)} percent, hue ${c.h.toFixed(0)} degrees`;\n      }\n      const cusp = findCusp(c.h, gamut as Gamut);\n      const V = clamp01(c.l + (c.c * (1 - cusp.l)) / Math.max(cusp.c, 1e-6));\n      const S = V > 1e-6 ? clamp01(c.c / (V * Math.max(cusp.c, 1e-6))) : 0;\n      return `Saturation ${(S * 100).toFixed(0)} percent, value ${(V * 100).toFixed(0)} percent, hue ${c.h.toFixed(0)} degrees`;\n    }\n    case \"oklch-hc\": {\n      const maxC =\n        gamut === \"none\"\n          ? chromaMax\n          : findMaxChroma(c.l, c.h, gamut as Gamut) || chromaMax;\n      return `Hue ${c.h.toFixed(0)} degrees, chroma ${c.c.toFixed(2)} of ${maxC.toFixed(2)}, lightness ${(c.l * 100).toFixed(0)} percent`;\n    }\n  }\n}\n\n/* ----------------------- gradient painting ----------------------- */\n/* Per-paint LUT of max chroma for the moving axis. Each row (or column for\n * oklch-hc) gets one bisection so the per-pixel cost stays a single multiply.\n * When gamut === \"none\" we skip the LUT and paint absolute chroma up to\n * chromaMax (the legacy unwrapped plane). */\n\nfunction paintGradient(\n  img: ImageData,\n  w: number,\n  h: number,\n  mode: AreaMode,\n  base: OklchColor,\n  chromaMax: number,\n  gamut: AreaGamut,\n  canvasIsP3: boolean,\n  softProof: boolean,\n) {\n  const data = img.data;\n  const ctx = buildWarpContext(mode, base, gamut, w, h);\n  // Soft-proof: build a parallel max-chroma context keyed to the *display*\n  // gamut. Per pixel we'll clamp chroma down to this surface so out-of-display\n  // OKLCH samples render as their hue/lightness-faithful gamut-mapped twin\n  // instead of a per-channel-clipped fake. Skipping the displayCtx entirely\n  // when render gamut already fits inside the display avoids a useless second\n  // LUT. \"none\" gamut opts out of warping in general, so soft-proof is a no-op.\n  const displayCap: AreaGamut =\n    gamut === \"none\"\n      ? \"none\"\n      : canvasIsP3\n        ? \"p3\"\n        : \"srgb\";\n  const needsSoftProof =\n    softProof && gamut !== \"none\" && isWiderThan(gamut, displayCap);\n  const displayCtx: WarpContext | null = needsSoftProof\n    ? buildWarpContext(mode, base, displayCap, w, h)\n    : null;\n\n  for (let yPx = 0; yPx < h; yPx++) {\n    const yn = yPx / (h - 1);\n    for (let xPx = 0; xPx < w; xPx++) {\n      const xn = xPx / (w - 1);\n\n      const [l, c, hue] = warpedSample(ctx, mode, base, chromaMax, xn, yn, xPx, yPx);\n      const cClamped = displayCtx\n        ? Math.min(c, displayMaxChroma(displayCtx, mode, l, xPx, yPx))\n        : c;\n\n      const lin = oklchToLinearSrgb(l, cClamped, hue);\n      const targetLin = canvasIsP3 ? linSrgbToLinP3(lin) : lin;\n      const idx = (yPx * w + xPx) * 4;\n      data[idx] = clampByte(srgbEncode(targetLin.r) * 255);\n      data[idx + 1] = clampByte(srgbEncode(targetLin.g) * 255);\n      data[idx + 2] = clampByte(srgbEncode(targetLin.b) * 255);\n      data[idx + 3] = 255;\n    }\n  }\n}\n\nfunction isWiderThan(a: AreaGamut, b: AreaGamut): boolean {\n  const rank: Record<AreaGamut, number> = { none: 99, rec2020: 3, p3: 2, srgb: 1 };\n  return rank[a] > rank[b];\n}\n\n/**\n * Read the display-gamut max chroma at this pixel using the soft-proof warp\n * context. For oklch-cl/oklch-hc the precomputed 1D LUT is row-/column-keyed.\n * For hsv-sv we evaluate the OKHSV-ish triangle around the locked-hue cusp:\n * chroma rises linearly from 0 at l=0 to cusp.c at l=cusp.l, then falls back\n * to 0 at l=1. That matches how `warpedSample` already builds hsv-sv samples,\n * so the clamp is consistent with the warp.\n */\nfunction displayMaxChroma(\n  ctx: WarpContext,\n  mode: AreaMode,\n  l: number,\n  xPx: number,\n  yPx: number,\n): number {\n  switch (ctx.kind) {\n    case \"none\":\n      return Number.POSITIVE_INFINITY;\n    case \"lut-y\":\n      return ctx.lut[yPx];\n    case \"lut-x\":\n      return ctx.lut[xPx];\n    case \"cusp\": {\n      if (mode !== \"hsv-sv\") return Number.POSITIVE_INFINITY;\n      const { l: cl, c: cc } = ctx.cusp;\n      if (l <= cl) return cc * (l / Math.max(cl, 1e-6));\n      return cc * ((1 - l) / Math.max(1 - cl, 1e-6));\n    }\n  }\n}\n\n/**\n * Per-paint warp resources. For oklch-cl and oklch-hc we precompute a 1D\n * max-chroma LUT keyed by the moving axis. For hsv-sv we precompute the\n * (locked-hue) cusp once. For \"none\" we hold no resources — the legacy raw\n * mapping uses chromaMax directly.\n */\ntype WarpContext =\n  | { kind: \"none\" }\n  | { kind: \"lut-y\"; lut: Float32Array }\n  | { kind: \"lut-x\"; lut: Float32Array }\n  | { kind: \"cusp\"; cusp: { l: number; c: number } };\n\nfunction buildWarpContext(\n  mode: AreaMode,\n  base: OklchColor,\n  gamut: AreaGamut,\n  w: number,\n  h: number,\n): WarpContext {\n  if (gamut === \"none\") return { kind: \"none\" };\n  switch (mode) {\n    case \"oklch-cl\": {\n      const lut = new Float32Array(h);\n      for (let j = 0; j < h; j++) {\n        const l = 1 - j / (h - 1);\n        lut[j] = findMaxChroma(l, base.h, gamut as Gamut);\n      }\n      return { kind: \"lut-y\", lut };\n    }\n    case \"oklch-hc\": {\n      const lut = new Float32Array(w);\n      for (let i = 0; i < w; i++) {\n        const hue = (i / (w - 1)) * 360;\n        lut[i] = findMaxChroma(base.l, hue, gamut as Gamut);\n      }\n      return { kind: \"lut-x\", lut };\n    }\n    case \"hsv-sv\":\n      return { kind: \"cusp\", cusp: findCusp(base.h, gamut as Gamut) };\n  }\n}\n\n/**\n * Read (l, c, hue) for a normalized (xn, yn) using the precomputed warp\n * context. `xPx`/`yPx` index into the LUT for oklch-cl/oklch-hc; hsv-sv and\n * \"none\" don't use them.\n */\nfunction warpedSample(\n  ctx: WarpContext,\n  mode: AreaMode,\n  base: OklchColor,\n  chromaMax: number,\n  xn: number,\n  yn: number,\n  xPx: number,\n  yPx: number,\n): [number, number, number] {\n  if (ctx.kind === \"none\") {\n    const ok = sampleRaw(mode, base, chromaMax, xn, yn);\n    return [ok.l, ok.c, ok.h];\n  }\n  switch (mode) {\n    case \"oklch-cl\": {\n      const l = 1 - yn;\n      const maxC = (ctx as { kind: \"lut-y\"; lut: Float32Array }).lut[yPx];\n      return [l, xn * maxC, base.h];\n    }\n    case \"hsv-sv\": {\n      const cusp = (ctx as { kind: \"cusp\"; cusp: { l: number; c: number } }).cusp;\n      const V = 1 - yn;\n      const S = xn;\n      const l = V * (1 - S * (1 - cusp.l));\n      const c = V * S * cusp.c;\n      return [l, c, base.h];\n    }\n    case \"oklch-hc\": {\n      const hue = xn * 360;\n      const maxC = (ctx as { kind: \"lut-x\"; lut: Float32Array }).lut[xPx];\n      return [base.l, (1 - yn) * maxC, hue];\n    }\n  }\n}\n\nfunction sampleRaw(\n  mode: AreaMode,\n  base: OklchColor,\n  chromaMax: number,\n  xn: number,\n  yn: number,\n): { l: number; c: number; h: number } {\n  switch (mode) {\n    case \"oklch-cl\":\n      return { l: 1 - yn, c: xn * chromaMax, h: base.h };\n    case \"hsv-sv\": {\n      const v = 1 - yn;\n      return { l: v, c: xn * v * chromaMax, h: base.h };\n    }\n    case \"oklch-hc\":\n      return { l: base.l, c: (1 - yn) * chromaMax, h: xn * 360 };\n  }\n}\n\n/**\n * Compute a warning gamut's boundary inside the *active render gamut's* warped\n * coordinate space, as SVG path strings in normalized 0..1 coords. Marching\n * squares on a 128² grid where each cell samples gamutSignedDistance against\n * the warning gamut, with sample positions warped through the active gamut's\n * max-chroma so the line lands on the correct (X, Y) inside the filled square.\n */\nfunction computeGamutPaths(\n  mode: AreaMode,\n  base: OklchColor,\n  chromaMax: number,\n  warningGamut: Gamut,\n  activeGamut: AreaGamut,\n): string[] {\n  const N = 128;\n  const stride = N + 1;\n  const sd = new Float32Array(stride * stride);\n  // Precompute hsv-sv's cusp once; without this the 16 384 grid samples would\n  // each rebuild it (52 findMaxChroma calls each → ~850 K total).\n  const cusp =\n    mode === \"hsv-sv\" && activeGamut !== \"none\"\n      ? findCusp(base.h, activeGamut as Gamut)\n      : null;\n  for (let j = 0; j <= N; j++) {\n    const yn = j / N;\n    for (let i = 0; i <= N; i++) {\n      const xn = i / N;\n      const ok =\n        activeGamut === \"none\"\n          ? sampleRaw(mode, base, chromaMax, xn, yn)\n          : sampleWarpedForLine(mode, base, activeGamut as Gamut, xn, yn, cusp);\n      sd[j * stride + i] = gamutSignedDistance(\n        { l: ok.l, c: ok.c, h: ok.h, alpha: 1 },\n        warningGamut,\n      );\n    }\n  }\n\n  const interp = (\n    ax: number, ay: number, av: number,\n    bx: number, by: number, bv: number,\n  ): [number, number] => {\n    const t = av / (av - bv);\n    return [ax + (bx - ax) * t, ay + (by - ay) * t];\n  };\n\n  const segs: number[] = []; // flat: [ax, ay, bx, by, ax, ay, bx, by, ...]\n  const pushSeg = (a: [number, number], b: [number, number]) => {\n    if (a[0] === b[0] && a[1] === b[1]) return;\n    segs.push(a[0], a[1], b[0], b[1]);\n  };\n  for (let j = 0; j < N; j++) {\n    for (let i = 0; i < N; i++) {\n      const aTL = sd[j * stride + i];\n      const aTR = sd[j * stride + i + 1];\n      const aBL = sd[(j + 1) * stride + i];\n      const aBR = sd[(j + 1) * stride + i + 1];\n      const code =\n        (aTL > 0 ? 8 : 0) |\n        (aTR > 0 ? 4 : 0) |\n        (aBR > 0 ? 2 : 0) |\n        (aBL > 0 ? 1 : 0);\n      if (code === 0 || code === 15) continue;\n      const x0 = i / N;\n      const y0 = j / N;\n      const x1 = (i + 1) / N;\n      const y1 = (j + 1) / N;\n      const top = () => interp(x0, y0, aTL, x1, y0, aTR);\n      const right = () => interp(x1, y0, aTR, x1, y1, aBR);\n      const bottom = () => interp(x0, y1, aBL, x1, y1, aBR);\n      const left = () => interp(x0, y0, aTL, x0, y1, aBL);\n      switch (code) {\n        case 1: case 14: pushSeg(left(), bottom()); break;\n        case 2: case 13: pushSeg(bottom(), right()); break;\n        case 3: case 12: pushSeg(left(), right()); break;\n        case 4: case 11: pushSeg(top(), right()); break;\n        case 6: case 9:  pushSeg(top(), bottom()); break;\n        case 7: case 8:  pushSeg(left(), top()); break;\n        case 5:  pushSeg(left(), top()); pushSeg(bottom(), right()); break;\n        case 10: pushSeg(left(), bottom()); pushSeg(top(), right()); break;\n      }\n    }\n  }\n\n  const segCount = segs.length / 4;\n  if (segCount === 0) return [];\n\n  // Build endpoint map keyed by quantized coords. Endpoints from adjacent cells\n  // share the same `interp` inputs along their shared edge, so they collide\n  // exactly — quantization only protects against float ULP drift.\n  const Q = 1_000_000;\n  const key = (x: number, y: number) => `${(x * Q) | 0},${(y * Q) | 0}`;\n  type Endpoint = { seg: number; end: 0 | 1 };\n  const endpointMap = new Map<string, Endpoint[]>();\n  const addEp = (k: string, e: Endpoint) => {\n    const arr = endpointMap.get(k);\n    if (arr) arr.push(e);\n    else endpointMap.set(k, [e]);\n  };\n  for (let s = 0; s < segCount; s++) {\n    const o = s * 4;\n    addEp(key(segs[o], segs[o + 1]), { seg: s, end: 0 });\n    addEp(key(segs[o + 2], segs[o + 3]), { seg: s, end: 1 });\n  }\n\n  const used = new Uint8Array(segCount);\n  const popEndpointAt = (k: string): Endpoint | null => {\n    const arr = endpointMap.get(k);\n    if (!arr) return null;\n    while (arr.length > 0) {\n      const e = arr.pop()!;\n      if (!used[e.seg]) return e;\n    }\n    return null;\n  };\n\n  const polylines: number[][] = [];\n  for (let start = 0; start < segCount; start++) {\n    if (used[start]) continue;\n    used[start] = 1;\n    const o = start * 4;\n    const pts: number[] = [segs[o], segs[o + 1], segs[o + 2], segs[o + 3]];\n\n    // Extend forward off the tail.\n    while (true) {\n      const tx = pts[pts.length - 2];\n      const ty = pts[pts.length - 1];\n      const e = popEndpointAt(key(tx, ty));\n      if (!e) break;\n      used[e.seg] = 1;\n      const no = e.seg * 4;\n      if (e.end === 0) pts.push(segs[no + 2], segs[no + 3]);\n      else pts.push(segs[no], segs[no + 1]);\n    }\n    // Extend backward off the head.\n    while (true) {\n      const hx = pts[0];\n      const hy = pts[1];\n      const e = popEndpointAt(key(hx, hy));\n      if (!e) break;\n      used[e.seg] = 1;\n      const no = e.seg * 4;\n      if (e.end === 0) pts.unshift(segs[no + 2], segs[no + 3]);\n      else pts.unshift(segs[no], segs[no + 1]);\n    }\n\n    polylines.push(pts);\n  }\n\n  const fmt = (v: number) => v.toFixed(5);\n  return polylines.map((pts) => {\n    const last = pts.length;\n    const closed =\n      last >= 4 && pts[0] === pts[last - 2] && pts[1] === pts[last - 1];\n    let d = `M${fmt(pts[0])},${fmt(pts[1])}`;\n    for (let i = 2; i < last; i += 2) {\n      d += `L${fmt(pts[i])},${fmt(pts[i + 1])}`;\n    }\n    if (closed) d += \"Z\";\n    return d;\n  });\n}\n\n/**\n * Sample helper for the warning-line marching squares. Mirrors `sampleAt`\n * but takes raw inputs (no `gamut === \"none\"` branch — callers handle it).\n * `cusp` is required for hsv-sv mode; precomputed by the caller for speed.\n */\nfunction sampleWarpedForLine(\n  mode: AreaMode,\n  base: OklchColor,\n  activeGamut: Gamut,\n  xn: number,\n  yn: number,\n  cusp: { l: number; c: number } | null,\n): { l: number; c: number; h: number } {\n  switch (mode) {\n    case \"oklch-cl\": {\n      const l = 1 - yn;\n      const maxC = findMaxChroma(l, base.h, activeGamut);\n      return { l, c: xn * maxC, h: base.h };\n    }\n    case \"hsv-sv\": {\n      const k = cusp ?? findCusp(base.h, activeGamut);\n      const V = 1 - yn;\n      const S = xn;\n      const l = V * (1 - S * (1 - k.l));\n      const c = V * S * k.c;\n      return { l, c, h: base.h };\n    }\n    case \"oklch-hc\": {\n      const h = xn * 360;\n      const maxC = findMaxChroma(base.l, h, activeGamut);\n      return { l: base.l, c: (1 - yn) * maxC, h };\n    }\n  }\n}\n\nfunction srgbEncode(v: number) {\n  const x = v < 0 ? 0 : v > 1 ? 1 : v;\n  return x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;\n}\n\nfunction clampByte(x: number) {\n  return x < 0 ? 0 : x > 255 ? 255 : Math.round(x);\n}\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/chroma.tsx",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/chroma.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useColorPickerContext } from \"../context\";\nimport { formatColor } from \"../lib/color\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * OKLCH chroma is unbounded in principle but practically maxes out\n * around ~0.4 for the wide hues; gamut-clamping handles anything\n * higher. Matches the slider/typed range in `lib/channels.ts`.\n */\nconst CHROMA_MAX = 0.4;\n\nexport interface ChromaProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onKeyDown\"> {\n  orientation?: \"horizontal\" | \"vertical\";\n}\n\nexport const Chroma = React.forwardRef<HTMLDivElement, ChromaProps>(\n  function Chroma({ orientation = \"horizontal\", className, ...rest }, ref) {\n    const { color, setComponent } = useColorPickerContext();\n    const trackRef = React.useRef<HTMLDivElement | null>(null);\n    React.useImperativeHandle(ref, () => trackRef.current as HTMLDivElement);\n\n    const moveTo = (clientCoord: number) => {\n      const el = trackRef.current;\n      if (!el) return;\n      const rect = el.getBoundingClientRect();\n      const ratio =\n        orientation === \"horizontal\"\n          ? (clientCoord - rect.left) / rect.width\n          : // Vertical is bottom-anchored (min at the bottom), matching the\n            // Base UI variant and ArrowUp = increase.\n            1 - (clientCoord - rect.top) / rect.height;\n      const clamped = Math.max(0, Math.min(1, ratio));\n      setComponent(\"c\", clamped * CHROMA_MAX);\n    };\n\n    const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n      (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);\n      moveTo(orientation === \"horizontal\" ? e.clientX : e.clientY);\n    };\n    const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n      if (e.buttons !== 1) return;\n      moveTo(orientation === \"horizontal\" ? e.clientX : e.clientY);\n    };\n    const releaseCapture = (e: React.PointerEvent<HTMLDivElement>) => {\n      const el = e.currentTarget as HTMLDivElement;\n      if (el.hasPointerCapture(e.pointerId))\n        el.releasePointerCapture(e.pointerId);\n    };\n\n    const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n      const big = e.shiftKey ? 0.05 : 0.005;\n      let next = color.c;\n      switch (e.key) {\n        case \"ArrowLeft\":\n        case \"ArrowDown\":\n          next -= big;\n          break;\n        case \"ArrowRight\":\n        case \"ArrowUp\":\n          next += big;\n          break;\n        case \"Home\":\n          next = 0;\n          break;\n        case \"End\":\n          next = CHROMA_MAX;\n          break;\n        case \"PageUp\":\n          next += 0.05;\n          break;\n        case \"PageDown\":\n          next -= 0.05;\n          break;\n        default:\n          return;\n      }\n      e.preventDefault();\n      setComponent(\"c\", Math.max(0, Math.min(CHROMA_MAX, next)));\n    };\n\n    const isVertical = orientation === \"vertical\";\n    // Build the gradient at the current hue & lightness so the ramp shows\n    // how chroma changes _their_ color, not a generic gray→vivid ramp.\n    const stops = React.useMemo(() => {\n      const samples = 8;\n      const arr: string[] = [];\n      for (let i = 0; i <= samples; i++) {\n        const c = (i / samples) * CHROMA_MAX;\n        arr.push(formatColor({ l: color.l, c, h: color.h, alpha: 1 }, \"oklch\"));\n      }\n      return arr.join(\", \");\n    }, [color.h, color.l]);\n\n    const ratio = Math.max(0, Math.min(1, color.c / CHROMA_MAX));\n\n    return (\n      <div\n        ref={trackRef}\n        data-slot=\"color-picker-chroma\"\n        role=\"slider\"\n        aria-label=\"Chroma\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={Math.round(ratio * 100)}\n        aria-valuetext={`${color.c.toFixed(3)}`}\n        aria-orientation={orientation}\n        tabIndex={0}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={releaseCapture}\n        onPointerCancel={releaseCapture}\n        onKeyDown={onKeyDown}\n        className={cn(\n          \"relative cursor-pointer rounded-full outline-none touch-none\",\n          isVertical ? \"h-32 w-3\" : \"h-3 w-full\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover\",\n          className,\n        )}\n        style={{\n          background: isVertical\n            ? `linear-gradient(to top, ${stops})`\n            : `linear-gradient(to right, ${stops})`,\n        }}\n        {...rest}\n      >\n        <div\n          className=\"pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.6)]\"\n          style={\n            isVertical\n              ? {\n                  left: \"50%\",\n                  top: `calc((1 - ${ratio}) * (100% - 16px) + 8px)`,\n                  background: formatColor({ ...color, alpha: 1 }, \"oklch\"),\n                }\n              : {\n                  left: `calc(${ratio} * (100% - 16px) + 8px)`,\n                  top: \"50%\",\n                  background: formatColor({ ...color, alpha: 1 }, \"oklch\"),\n                }\n          }\n        />\n      </div>\n    );\n  },\n);\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/css-input.tsx",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/css-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useColorPickerContext } from \"../context\";\nimport { cn } from \"@/lib/utils\";\nimport { FieldInput, FieldShell } from \"./field\";\n\nexport interface CssInputProps\n  extends Omit<\n    React.InputHTMLAttributes<HTMLInputElement>,\n    \"value\" | \"onChange\"\n  > {}\n\nexport const CssInput = React.forwardRef<HTMLInputElement, CssInputProps>(\n  function CssInput({ className, ...rest }, ref) {\n    const { formatted, setFromString } = useColorPickerContext();\n    // Sync draft when canonical value changes externally (slider drags etc.)\n    // via in-render adjustment — no useEffect needed.\n    const [draft, setDraft] = React.useState(formatted);\n    const [prevFormatted, setPrevFormatted] = React.useState(formatted);\n    const [error, setError] = React.useState(false);\n    if (formatted !== prevFormatted) {\n      setPrevFormatted(formatted);\n      setDraft(formatted);\n      setError(false);\n    }\n\n    const commit = (value: string) => {\n      const ok = setFromString(value.trim());\n      setError(!ok);\n    };\n\n    return (\n      <FieldShell\n        data-slot=\"color-picker-input\"\n        className={cn(\"w-full\", className)}\n      >\n        <FieldInput\n          ref={ref}\n          value={draft}\n          aria-invalid={error || undefined}\n          aria-label=\"Color value\"\n          onChange={(e) => {\n            setDraft(e.target.value);\n            setError(false);\n          }}\n          onBlur={(e) => commit(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") {\n              e.preventDefault();\n              commit(e.currentTarget.value);\n            } else if (e.key === \"Escape\") {\n              setDraft(formatted);\n              setError(false);\n            }\n          }}\n          className={cn(\n            \"flex-1 px-2 text-left\",\n            error && \"text-destructive\",\n          )}\n          {...rest}\n        />\n      </FieldShell>\n    );\n  },\n);\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/preview.tsx",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/preview.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useColorPickerContext } from \"../context\";\nimport { formatColor } from \"../lib/color\";\nimport { cn } from \"@/lib/utils\";\nimport { CHECKERBOARD_LG, SAMPLE_EDGE } from \"../lib/constants\";\n\nexport interface PreviewProps extends React.HTMLAttributes<HTMLDivElement> {}\n\nexport const Preview = React.forwardRef<HTMLDivElement, PreviewProps>(function Preview(\n  { className, ...rest },\n  ref,\n) {\n  const { color, background } = useColorPickerContext();\n  const fg = formatColor(color, \"p3\");\n  const bg = formatColor(background, \"p3\");\n  return (\n    <div\n      ref={ref}\n      data-slot=\"color-picker-preview\"\n      role=\"img\"\n      aria-label=\"Color preview over background\"\n      className={cn(\n        \"relative size-10 shrink-0 overflow-hidden rounded-md\",\n        SAMPLE_EDGE,\n        className,\n      )}\n      style={{\n        backgroundImage: CHECKERBOARD_LG,\n        backgroundSize: \"12px 12px\",\n      }}\n      {...rest}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"absolute inset-0\"\n        style={{ background: bg }}\n      />\n      <div\n        aria-hidden=\"true\"\n        className=\"absolute inset-0\"\n        style={{ background: fg }}\n      />\n    </div>\n  );\n});\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/eye-dropper.tsx",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/eye-dropper.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Pipette } from \"lucide-react\";\nimport { useColorPickerContext } from \"../context\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ninterface EyeDropperLike {\n  open: (opts?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>;\n}\n\ndeclare global {\n  interface Window {\n    EyeDropper?: { new (): EyeDropperLike };\n  }\n}\n\nconst subscribeNoop = () => () => {};\nconst getEyeDropperSupportClient = () =>\n  typeof window !== \"undefined\" && typeof window.EyeDropper === \"function\";\nconst getEyeDropperSupportServer = () => false;\n\nexport interface EyeDropperProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}\n\nexport const EyeDropper = React.forwardRef<HTMLButtonElement, EyeDropperProps>(function EyeDropper(\n  { className, ...rest },\n  ref,\n) {\n  const { setColor } = useColorPickerContext();\n  // Client-only feature detection without a hydration mismatch and without the\n  // two-paint flash that an `useEffect(setState)` pattern introduces. The\n  // server snapshot returns `false` (matching SSR's empty render); the client\n  // snapshot reads the real value during hydration's reconciliation pass.\n  const supported = React.useSyncExternalStore(\n    subscribeNoop,\n    getEyeDropperSupportClient,\n    getEyeDropperSupportServer,\n  );\n\n  const [active, setActive] = React.useState(false);\n\n  if (!supported) return null;\n\n  const onClick = async () => {\n    setActive(true);\n    try {\n      const ed = new window.EyeDropper!();\n      const result = await ed.open();\n      if (result?.sRGBHex) setColor(result.sRGBHex);\n    } catch {\n      // user cancelled\n    } finally {\n      setActive(false);\n    }\n  };\n\n  return (\n    <Button\n      ref={ref}\n      data-slot=\"color-picker-eye-dropper\"\n      data-state={active ? \"on\" : \"off\"}\n      type=\"button\"\n      variant=\"outline\"\n      size=\"icon-sm\"\n      aria-label=\"Pick color from screen\"\n      aria-pressed={active}\n      onClick={onClick}\n      className={cn(\n        \"cursor-pointer\",\n        active && \"border-foreground/70 bg-foreground/10 text-foreground hover:bg-foreground/15\",\n        className,\n      )}\n      {...rest}\n    >\n      <Pipette className=\"size-4\" />\n    </Button>\n  );\n});\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/gamut-badge-shared.ts",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/gamut-badge-shared.ts",
      "content": "// Tooltip-agnostic internals of GamutBadge, shared by the Radix shell\n// (parts/gamut-badge.tsx) and the Base UI shell\n// (fill-picker-base/parts/gamut-badge.tsx).\n\nimport type * as React from \"react\";\nimport type { GamutInfo } from \"../lib/types\";\n\nexport interface GamutBadgeProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Show the \"Gamut\" prefix label. Default true. */\n  showLabel?: boolean;\n}\n\nexport const GAMUT_BADGE_CLASS =\n  \"inline-flex w-full cursor-default items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1.5 text-xs\";\n\nexport function gamutLabel(\n  gamut: Pick<GamutInfo, \"inSrgb\" | \"inP3\" | \"inRec2020\">,\n): string {\n  if (!gamut.inSrgb && gamut.inP3) return \"P3\";\n  if (!gamut.inP3 && gamut.inRec2020) return \"Rec.2020\";\n  if (!gamut.inRec2020) return \"Out of gamut\";\n  return \"sRGB\";\n}\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/contrast-readout-shared.tsx",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/contrast-readout-shared.tsx",
      "content": "\"use client\";\n\n// Tooltip-agnostic internals of ContrastReadout, shared by the Radix shell\n// (parts/contrast-readout.tsx) and the Base UI shell\n// (fill-picker-base/parts/contrast-readout.tsx). Radix and Base UI spell\n// tooltip composition incompatibly (asChild vs render, delayDuration vs\n// delay), so each variant owns only that wiring; everything else lives here.\n\nimport * as React from \"react\";\nimport { Check, X } from \"lucide-react\";\nimport { useColorPickerContext } from \"../context\";\nimport { formatColor } from \"../lib/color\";\nimport { cn } from \"@/lib/utils\";\nimport { CHECKERBOARD_SM } from \"../lib/constants\";\n\nexport type ContrastMetric = \"wcag\" | \"apca\";\n\nexport interface ContrastReadoutProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * Which contrast metrics are available. The first entry is shown by default;\n   * if more than one is provided, the readout becomes a button and the user\n   * clicks it to cycle to the next metric. Defaults to [\"wcag\"].\n   */\n  metrics?: ContrastMetric[];\n  /** Override the initial metric. Must be present in `metrics`. */\n  defaultMetric?: ContrastMetric;\n  /** Show the metric label (\"WCAG\" / \"APCA\"). Default true. */\n  showLabel?: boolean;\n  /** Show the numeric value (ratio / Lc). Default true. */\n  showValue?: boolean;\n  /** Show the pass/fail level badges (AA, AAA, body, headline, fail). Default true. */\n  showBadges?: boolean;\n}\n\nconst DEFAULT_METRICS: ContrastMetric[] = [\"wcag\"];\n\nexport interface PassRow {\n  ok: boolean;\n  label: string;\n  detail: string;\n}\n\nexport const CONTRAST_READOUT_CLASS =\n  \"flex w-full items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1.5 text-xs\";\n\nexport function useContrastReadout({\n  metrics = DEFAULT_METRICS,\n  defaultMetric,\n  showLabel = true,\n  showValue = true,\n  showBadges = true,\n}: Pick<\n  ContrastReadoutProps,\n  \"metrics\" | \"defaultMetric\" | \"showLabel\" | \"showValue\" | \"showBadges\"\n>) {\n  // Empty metrics would leave activeMetric undefined and silently render\n  // the APCA branch — treat [] as \"not provided\".\n  if (metrics.length === 0) metrics = DEFAULT_METRICS;\n\n  const { contrast, color, background } = useColorPickerContext();\n  const fgCss = formatColor(color, \"p3\");\n  const bgCss = formatColor(background, \"p3\");\n  const initial =\n    defaultMetric && metrics.includes(defaultMetric) ? defaultMetric : metrics[0];\n  const [active, setActive] = React.useState<ContrastMetric>(initial);\n\n  // If the parent narrows `metrics` so the stored choice is no longer\n  // offered, fall back to the first option. Derived, not synced with\n  // setState-during-render (which is NOT visible to the rest of the same\n  // render pass): the stale stored value simply stops being used, and the\n  // user's choice resurrects if the parent restores that metric.\n  const activeMetric = metrics.includes(active) ? active : metrics[0];\n\n  const togglable = metrics.length > 1;\n\n  // Spoken summary of a metric — the ratio and pass/fail must be part of the\n  // accessible name, not just the visible text the aria-label would\n  // otherwise override.\n  const describeMetric = (metric: ContrastMetric) =>\n    metric === \"wcag\"\n      ? `WCAG ${contrast.wcag.toFixed(2)} to 1, AA ${\n          contrast.wcagLevel.aaNormal ? \"pass\" : \"fail\"\n        }, AAA ${contrast.wcagLevel.aaaNormal ? \"pass\" : \"fail\"}`\n      : `APCA Lc ${contrast.apca.toFixed(1)}, ${apcaLevel(contrast.apca)}`;\n\n  const summary = describeMetric(activeMetric);\n  // Announced only when the user cycles metrics — announcing every color\n  // change would flood the SR queue during drags.\n  const [cycleAnnouncement, setCycleAnnouncement] = React.useState(\"\");\n  const cycle = () => {\n    const i = metrics.indexOf(activeMetric);\n    const next = metrics[(i + 1) % metrics.length];\n    setActive(next);\n    setCycleAnnouncement(describeMetric(next));\n  };\n  const nextMetric = metrics[(metrics.indexOf(activeMetric) + 1) % metrics.length];\n\n  const body =\n    activeMetric === \"wcag\" ? (\n      <WcagBody\n        wcag={contrast.wcag}\n        aa={contrast.wcagLevel.aaNormal}\n        aaa={contrast.wcagLevel.aaaNormal}\n        showLabel={showLabel}\n        showValue={showValue}\n        showBadges={showBadges}\n      />\n    ) : (\n      <ApcaBody\n        lc={contrast.apca}\n        showLabel={showLabel}\n        showValue={showValue}\n        showBadges={showBadges}\n      />\n    );\n\n  const popover =\n    activeMetric === \"wcag\"\n      ? wcagPopover(\n          contrast.wcag,\n          contrast.wcagLevel.aaNormal,\n          contrast.wcagLevel.aaaNormal,\n        )\n      : apcaPopover(contrast.apca);\n\n  return {\n    active: activeMetric,\n    togglable,\n    nextMetric,\n    summary,\n    cycle,\n    cycleAnnouncement,\n    body,\n    popover,\n    fgCss,\n    bgCss,\n  };\n}\n\nfunction apcaLevel(lc: number): \"headline\" | \"body\" | \"fail\" {\n  const abs = Math.abs(lc);\n  return abs >= 75 ? \"headline\" : abs >= 60 ? \"body\" : \"fail\";\n}\n\nexport function WcagBody({\n  wcag,\n  aa,\n  aaa,\n  showLabel,\n  showValue,\n  showBadges,\n}: {\n  wcag: number;\n  aa: boolean;\n  aaa: boolean;\n  showLabel: boolean;\n  showValue: boolean;\n  showBadges: boolean;\n}) {\n  return (\n    <>\n      {(showLabel || showValue) && (\n        <div className=\"flex items-center gap-1.5\">\n          {showLabel && <span className=\"text-muted-foreground\">WCAG</span>}\n          {showValue && (\n            <span className=\"font-mono font-medium\">{wcag.toFixed(2)}:1</span>\n          )}\n        </div>\n      )}\n      {showBadges && (\n        <div className=\"flex items-center gap-1\">\n          <Badge ok={aa}>AA</Badge>\n          <Badge ok={aaa}>AAA</Badge>\n        </div>\n      )}\n    </>\n  );\n}\n\nexport function ApcaBody({\n  lc,\n  showLabel,\n  showValue,\n  showBadges,\n}: {\n  lc: number;\n  showLabel: boolean;\n  showValue: boolean;\n  showBadges: boolean;\n}) {\n  const level = apcaLevel(lc);\n  return (\n    <>\n      {(showLabel || showValue) && (\n        <div className=\"flex items-center gap-1.5\">\n          {showLabel && <span className=\"text-muted-foreground\">APCA</span>}\n          {showValue && (\n            <span className=\"font-mono font-medium\">Lc {lc.toFixed(1)}</span>\n          )}\n        </div>\n      )}\n      {showBadges && (\n        <div className=\"flex items-center gap-1\">\n          <Badge ok={level !== \"fail\"}>\n            {level === \"headline\" ? \"headline\" : level === \"body\" ? \"body\" : \"fail\"}\n          </Badge>\n        </div>\n      )}\n    </>\n  );\n}\n\nfunction Badge({ ok, children }: { ok: boolean; children: React.ReactNode }) {\n  return (\n    <span\n      aria-label={typeof children === \"string\" ? `${children} ${ok ? \"passes\" : \"fails\"}` : undefined}\n      className={cn(\n        \"rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider\",\n        ok\n          ? \"bg-emerald-500/15 text-emerald-700 dark:text-emerald-400\"\n          : \"bg-red-500/15 text-red-700 dark:text-red-400\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport function ContrastPopoverPanel({\n  title,\n  rows,\n  fg,\n  bg,\n  footer,\n}: {\n  title: string;\n  rows: PassRow[];\n  fg: string;\n  bg: string;\n  footer?: string;\n}) {\n  return (\n    <div className=\"flex flex-col gap-2 text-left\">\n      <div className=\"flex items-center justify-between gap-3\">\n        <div className=\"text-[11px] font-semibold uppercase tracking-wider text-muted-foreground\">\n          {title}\n        </div>\n        <div\n          aria-hidden\n          className=\"flex shrink-0 overflow-hidden rounded border border-border\"\n          title={`fg ${fg} on bg ${bg}`}\n        >\n          <Chip color={fg} />\n          <Chip color={bg} />\n        </div>\n      </div>\n      <ul className=\"flex flex-col gap-1.5\">\n        {rows.map((r) => (\n          <li key={r.label} className=\"flex items-start gap-2\">\n            <span\n              aria-hidden\n              className={cn(\n                \"mt-0.5 inline-flex size-3.5 shrink-0 items-center justify-center rounded-full\",\n                r.ok\n                  ? \"bg-emerald-500/20 text-emerald-600 dark:text-emerald-400\"\n                  : \"bg-red-500/20 text-red-600 dark:text-red-400\",\n              )}\n            >\n              {r.ok ? <Check className=\"size-2.5\" /> : <X className=\"size-2.5\" />}\n            </span>\n            <div className=\"flex flex-col gap-0.5\">\n              <span className=\"text-xs font-medium leading-tight\">{r.label}</span>\n              <span className=\"text-[11px] leading-snug text-muted-foreground\">\n                {r.detail}\n              </span>\n            </div>\n          </li>\n        ))}\n      </ul>\n      {footer && (\n        <div className=\"border-t border-border pt-1.5 text-[11px] text-muted-foreground\">\n          {footer}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction Chip({ color }: { color: string }) {\n  return (\n    <span\n      className=\"block size-4\"\n      style={{ backgroundImage: CHECKERBOARD_SM, backgroundSize: \"8px 8px\" }}\n    >\n      <span className=\"block size-full\" style={{ background: color }} />\n    </span>\n  );\n}\n\nexport function wcagPopover(\n  ratio: number,\n  aa: boolean,\n  aaa: boolean,\n): { title: string; rows: PassRow[] } {\n  return {\n    title: `WCAG ${ratio.toFixed(2)}:1`,\n    rows: [\n      {\n        ok: aa,\n        label: aa ? \"Passes AA\" : \"Fails AA\",\n        detail: \"Body text needs ≥ 4.5:1\",\n      },\n      {\n        ok: aaa,\n        label: aaa ? \"Passes AAA\" : \"Fails AAA\",\n        detail: \"Enhanced body text needs ≥ 7:1\",\n      },\n    ],\n  };\n}\n\nexport function apcaPopover(lc: number): { title: string; rows: PassRow[] } {\n  const abs = Math.abs(lc);\n  const passesBody = abs >= 60;\n  const passesHeadline = abs >= 75;\n  return {\n    title: `APCA Lc ${lc.toFixed(1)}`,\n    rows: [\n      {\n        ok: passesBody,\n        label: passesBody ? \"Passes body text\" : \"Fails body text\",\n        detail: \"Body text needs |Lc| ≥ 60\",\n      },\n      {\n        ok: passesHeadline,\n        label: passesHeadline ? \"Passes headlines\" : \"Fails headlines\",\n        detail: \"Headline / large text needs |Lc| ≥ 75\",\n      },\n    ],\n  };\n}\n"
    },
    {
      "path": "registry/new-york/ui/fill-picker/parts/use-live-announce.ts",
      "type": "registry:ui",
      "target": "@ui/fill-picker/parts/use-live-announce.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * Debounced screen-reader announcements for 2D controls (`role=\"application\"`\n * pads/handles) that have no native value semantics. Render `liveText` inside\n * a visually hidden `aria-live=\"polite\"` element and call `announce` after\n * each keyboard-driven change; the debounce keeps held-down arrow keys from\n * flooding the SR queue.\n */\nexport function useLiveAnnounce(\n  delayMs = 150,\n): [string, (text: string) => void] {\n  const [liveText, setLiveText] = React.useState(\"\");\n  const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const announce = React.useCallback(\n    (text: string) => {\n      if (timerRef.current) clearTimeout(timerRef.current);\n      timerRef.current = setTimeout(() => setLiveText(text), delayMs);\n    },\n    [delayMs],\n  );\n  React.useEffect(\n    () => () => {\n      if (timerRef.current) clearTimeout(timerRef.current);\n    },\n    [],\n  );\n  return [liveText, announce];\n}\n"
    }
  ]
}
