shadimage
amplo
uicolor-picker-enginesource: ok · refreshed

Amplo Color Picker Engine

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.

npx shadcn add "https://amplo.ale.design/r/color-picker-engine.json"
free

Where it goes

testimonialstats

Interaction

animated-countertooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

utilsbutton

Files17 · source cached 10/8/2026, 6:16:10 AM

registry/new-york/ui/fill-picker/lib/types.ts
/**
 * Canonical color representation used throughout the picker.
 * All values normalized; alpha is always tracked.
 */
export interface OklchColor {
  /** Lightness 0..1 */
  l: number;
  /** Chroma 0..~0.4 (hard upper bound depends on hue + space) */
  c: number;
  /** Hue 0..360 (NaN when chroma === 0; treat as 0 for serialization) */
  h: number;
  /** Alpha 0..1 */
  alpha: number;
}

export type ColorFormat =
  | "hex"
  | "rgb"
  | "hsl"
  | "hsb"
  | "oklch"
  | "oklab"
  | "p3";

export type Gamut = "srgb" | "p3" | "rec2020";

export interface ContrastResult {
  /** WCAG 2.1 contrast ratio, 1..21 */
  wcag: number;
  /** WCAG 2.1 levels passed against the supplied background */
  wcagLevel: { aaNormal: boolean; aaLarge: boolean; aaaNormal: boolean; aaaLarge: boolean };
  /** APCA Lc value, signed, typical magnitude 0..108 */
  apca: number;
}

export interface GamutInfo {
  inSrgb: boolean;
  inP3: boolean;
  inRec2020: boolean;
}
registry/new-york/ui/fill-picker/lib/color.ts
import {
  parse as culoriParse,
  converter,
  formatHex,
  formatHex8,
  formatRgb,
  toGamut as culoriToGamut,
  wcagContrast,
  type Color,
} from "culori";
import type { ColorFormat, ContrastResult, Gamut, GamutInfo, OklchColor } from "./types";

const toOklch = converter("oklch");
const toRgb = converter("rgb");
const toHsl = converter("hsl");
const toHsv = converter("hsv");
const toOklab = converter("oklab");
const toP3 = converter("p3");

const GAMUT_EPSILON = 1e-4;
/** Chroma / lightness-edge threshold below which hue is meaningless. */
const ACHROMATIC_EPS = 1e-4;

function channelsInRange(c: { r?: number; g?: number; b?: number } | undefined): boolean {
  if (!c) return false;
  const r = c.r ?? 0;
  const g = c.g ?? 0;
  const b = c.b ?? 0;
  return (
    r >= -GAMUT_EPSILON && r <= 1 + GAMUT_EPSILON &&
    g >= -GAMUT_EPSILON && g <= 1 + GAMUT_EPSILON &&
    b >= -GAMUT_EPSILON && b <= 1 + GAMUT_EPSILON
  );
}

const toRec2020 = converter("rec2020");

function isInSrgb(ok: { mode: "oklch"; l: number; c: number; h: number; alpha: number }) {
  return channelsInRange(toRgb(ok));
}
function isInP3(ok: { mode: "oklch"; l: number; c: number; h: number; alpha: number }) {
  return channelsInRange(toP3(ok));
}
function isInRec2020(ok: { mode: "oklch"; l: number; c: number; h: number; alpha: number }) {
  return channelsInRange(toRec2020(ok));
}

export interface ParsedColor {
  color: OklchColor;
  /**
   * True when the source string carried no OKLCH-scale hue — an achromatic
   * hex/rgb/hsl/p3 where the round-trip loses hue and `color.h` is a
   * defaulted 0 or conversion noise (a p3 gray lands on c ≈ 1e-16, h 180).
   * False whenever the hue is meaningful: chromatic colors, and OKLCH
   * strings that author a hue even at zero chroma.
   */
  hueMissing: boolean;
}

/**
 * Parse a CSS color string into the canonical OKLCH form, reporting whether
 * the hue survived the round-trip. Returns null when the string can't be
 * parsed.
 */
export function parseColorDetailed(input: string): ParsedColor | null {
  if (!input || typeof input !== "string") return null;
  const parsed = culoriParse(input.trim());
  if (!parsed) return null;
  const oklch = toOklch(parsed);
  if (!oklch) return null;
  // Only OKLCH strings can author a hue at zero chroma; any other space's
  // near-zero chroma is a neutral whose finite hue is float noise.
  const hueMissing =
    !Number.isFinite(oklch.h) ||
    (parsed.mode !== "oklch" && (oklch.c ?? 0) <= ACHROMATIC_EPS);
  return {
    color: {
      l: clamp(oklch.l ?? 0, 0, 1),
      c: Math.max(oklch.c ?? 0, 0),
      h: hueMissing ? 0 : wrapHue(oklch.h as number),
      alpha: oklch.alpha ?? 1,
    },
    hueMissing,
  };
}

/**
 * Parse a CSS color string into the canonical OKLCH form.
 * Returns null when the string can't be parsed.
 */
export function parseColor(input: string): OklchColor | null {
  return parseColorDetailed(input)?.color ?? null;
}

export function isValidColor(input: string): boolean {
  return parseColor(input) !== null;
}

/**
 * Serialize a canonical OKLCH color to a CSS string in the chosen format.
 * For sRGB-targeted formats (hex/rgb/hsl/hsb) the color is gamut-mapped to sRGB first.
 * For P3 the color is gamut-mapped to P3.
 * OKLCH/OKLab outputs are unbounded.
 *
 * Numeric output is rounded (hue/percentages to 2 decimals, unit-range
 * channels to 4) so strings stay readable and a parent echoing `formatted`
 * back as a controlled `value` settles after one re-parse instead of
 * carrying float noise like `1.1e-14%`.
 */
export function formatColor(color: OklchColor, format: ColorFormat): string {
  switch (format) {
    case "hex": {
      const mapped = mapToGamutColor(color, "srgb");
      const rgb = toRgb({ mode: "oklch", ...oklchObj(mapped) });
      if (!rgb) return "#000000";
      return (color.alpha < 1 ? formatHex8(rgb) : formatHex(rgb)).toUpperCase();
    }
    case "rgb": {
      const mapped = mapToGamutColor(color, "srgb");
      const rgb = toRgb({ mode: "oklch", ...oklchObj(mapped) });
      return rgb ? formatRgb(rgb) : "rgb(0 0 0)";
    }
    case "hsl": {
      const hsl = toHsl(srgbClamped(color));
      if (!hsl) return "hsl(0 0% 0%)";
      return `hsl(${fmtHue(hsl.h)} ${round(hsl.s * 100, 2)}% ${round(hsl.l * 100, 2)}%${fmtAlpha(color.alpha)})`;
    }
    case "hsb": {
      // CSS has no hsb()/hsv() function. Emit culori's `color(--hsv h s v)`
      // (s/v in 0..1): not paintable by browsers, but it is the one HSB
      // syntax `parseColor` reads back, so the Input field round-trips.
      const hsv = toHsv(srgbClamped(color));
      if (!hsv) return "color(--hsv 0 0 0)";
      return `color(--hsv ${fmtHue(hsv.h)} ${round(hsv.s, 4)} ${round(hsv.v, 4)}${fmtAlpha(color.alpha)})`;
    }
    case "oklch": {
      const { l, c, h, alpha } = color;
      const lStr = round(l, 4);
      const cStr = round(c, 4);
      const hStr = round(h, 2);
      return `oklch(${lStr} ${cStr} ${hStr}${fmtAlpha(alpha)})`;
    }
    case "oklab": {
      const lab = toOklab({ mode: "oklch", ...oklchObj(color) });
      if (!lab) return "oklab(0 0 0)";
      return `oklab(${round(lab.l, 4)} ${round(lab.a, 4)} ${round(lab.b, 4)}${fmtAlpha(color.alpha)})`;
    }
    case "p3": {
      const mapped = mapToGamutColor(color, "p3");
      const p3 = toP3({ mode: "oklch", ...oklchObj(mapped) });
      if (!p3) return "color(display-p3 0 0 0)";
      return `color(display-p3 ${round(p3.r, 4)} ${round(p3.g, 4)} ${round(p3.b, 4)}${fmtAlpha(color.alpha)})`;
    }
  }
}

/** Hue for hsl/hsb output: undefined (achromatic) → 0, wrapped after rounding. */
function fmtHue(h: number | undefined): number {
  if (!Number.isFinite(h)) return 0;
  const r = round(wrapHue(h as number), 2);
  return r >= 360 ? 0 : r;
}

/** Alpha suffix for space-separated CSS syntax; empty when opaque. */
function fmtAlpha(alpha: number): string {
  return alpha < 1 ? ` / ${round(alpha, 3)}` : "";
}

/** Convenience: pull bare OKLCH numeric fields. */
function oklchObj(c: OklchColor) {
  return { l: c.l, c: c.c, h: c.h, alpha: c.alpha };
}

const ALL_FORMATS: ColorFormat[] = [
  "hex",
  "rgb",
  "hsl",
  "hsb",
  "oklch",
  "oklab",
  "p3",
];

/**
 * Serialize an OKLCH color to every supported output format at once.
 * Useful when consumers need both the canonical lossless form (oklch) and
 * a fallback string (hex) without re-running format selection.
 */
export function formatAll(color: OklchColor): Record<ColorFormat, string> {
  const out = {} as Record<ColorFormat, string>;
  for (const f of ALL_FORMATS) out[f] = formatColor(color, f);
  return out;
}

/**
 * Continuous signed distance from the target gamut surface.
 *  - 0 on the boundary
 *  - <0 inside the gamut (any channel exceeds [0,1] by the most-negative amount)
 *  - >0 outside the gamut (channels exceed by the most-positive amount)
 *
 * Drives the marching-squares boundary line. We use inline OKLab → linear RGB
 * math (zero crossings unchanged from gamma-encoded RGB) so the per-grid-point
 * cost stays under a microsecond — at 128² × 2 lines per OKLCH-mode repaint
 * the culori path was the new perf hot spot.
 */
export function gamutSignedDistance(color: OklchColor, gamut: Gamut): number {
  const lin = oklchToLinearSrgb(color.l, color.c, color.h);
  const target =
    gamut === "srgb"
      ? lin
      : gamut === "p3"
        ? linSrgbToLinP3(lin)
        : linSrgbToLinRec2020(lin);
  const lo = -Math.min(target.r, target.g, target.b);
  const hi = Math.max(target.r, target.g, target.b) - 1;
  return Math.max(lo, hi);
}

/**
 * OKLCH → linear sRGB via Björn Ottosson's OKLab matrix. Output channels can
 * be negative or >1 for colors outside sRGB; that's the point — wider gamuts
 * are just `linSrgbToLinP3` / `linSrgbToLinRec2020` away.
 */
export function oklchToLinearSrgb(
  l: number,
  c: number,
  hDeg: number,
): { r: number; g: number; b: number } {
  const h = (hDeg * Math.PI) / 180;
  const a = c * Math.cos(h);
  const b = c * Math.sin(h);

  const lp = l + 0.3963377774 * a + 0.2158037573 * b;
  const mp = l - 0.1055613458 * a - 0.0638541728 * b;
  const sp = l - 0.0894841775 * a - 1.291485548 * b;

  const L = lp * lp * lp;
  const M = mp * mp * mp;
  const S = sp * sp * sp;

  return {
    r: 4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S,
    g: -1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S,
    b: -0.0041960863 * L - 0.7034186147 * M + 1.707614701 * S,
  };
}

/* Linear sRGB → linear Display-P3 (CSS Color 4 reference matrix). */
export function linSrgbToLinP3(c: {
  r: number;
  g: number;
  b: number;
}): { r: number; g: number; b: number } {
  return {
    r: 0.8224621 * c.r + 0.1775380 * c.g + 0.0 * c.b,
    g: 0.0331942 * c.r + 0.9668058 * c.g + 0.0 * c.b,
    b: 0.0170828 * c.r + 0.0723976 * c.g + 0.9105196 * c.b,
  };
}

/* Linear sRGB → linear Rec.2020 (CSS Color 4 reference matrix). */
export function linSrgbToLinRec2020(c: {
  r: number;
  g: number;
  b: number;
}): { r: number; g: number; b: number } {
  return {
    r: 0.6274039 * c.r + 0.3292830 * c.g + 0.0433131 * c.b,
    g: 0.0690973 * c.r + 0.9195404 * c.g + 0.0113623 * c.b,
    b: 0.0163914 * c.r + 0.0880133 * c.g + 0.8955953 * c.b,
  };
}

/**
 * Bisect for the maximum OKLCH chroma at this lightness and hue that still
 * fits inside the target gamut. Returns 0 at L=0 or L=1 (those are achromatic
 * by definition). Cost: ~12 iterations × 3 matrix-mults per call.
 *
 * `epsilon` controls the bisection's strictness — a positive value relaxes
 * the in-gamut check (allows channels out by `epsilon`); zero is strict; a
 * negative value shrinks the gamut. Default 0 is strict so the returned
 * chroma is *guaranteed* in-gamut; the area's bead-clamp re-pin and
 * culori-based `gamutInfo` (which use their own `GAMUT_EPSILON` tolerance)
 * will then never tip the picked color out.
 *
 * The `marginalChroma` knob trims an absolute amount off the final result
 * to absorb bisection precision (~5e-5) plus OKLab matrix drift. Without it,
 * the result can sit on the strict surface where the round-trip via culori's
 * `toGamut` and the area's hue re-pin can push it out by float ULPs.
 */
export function findMaxChroma(
  l: number,
  hDeg: number,
  gamut: Gamut,
  options: { iterations?: number; epsilon?: number; marginalChroma?: number } = {},
): number {
  if (l <= 0 || l >= 1) return 0;
  const iterations = options.iterations ?? 14;
  const eps = options.epsilon ?? 0;
  const marginalChroma = options.marginalChroma ?? 1e-3;

  const inGamut = (c: number): boolean => {
    const lin = oklchToLinearSrgb(l, c, hDeg);
    const target =
      gamut === "srgb"
        ? lin
        : gamut === "p3"
          ? linSrgbToLinP3(lin)
          : linSrgbToLinRec2020(lin);
    return (
      target.r >= -eps && target.r <= 1 + eps &&
      target.g >= -eps && target.g <= 1 + eps &&
      target.b >= -eps && target.b <= 1 + eps
    );
  };

  // Find an upper bound that is definitely outside. 0.5 covers Rec.2020 reds
  // (max chroma ≈ 0.4 in OKLCH); double until out, capped at 2.0 for safety.
  let hi = 0.5;
  while (inGamut(hi) && hi < 2) hi *= 2;
  let lo = 0;
  for (let i = 0; i < iterations; i++) {
    const mid = (lo + hi) / 2;
    if (inGamut(mid)) lo = mid;
    else hi = mid;
  }
  return Math.max(0, lo - marginalChroma);
}

/**
 * Mildly saturated OKLCH (l, c) at which an achromatic color's hue is read
 * on the HSL/HSV scale. A gray's own HSL/HSV hue is undefined or float
 * noise, so the slider, the channel input's H field (`lib/channels.ts`), and
 * hue writes all read it off this probe on the stored OKLCH hue instead —
 * sharing one constant keeps the three in agreement.
 */
export const ACHROMATIC_HUE_PROBE = { l: 0.6, c: 0.08 } as const;

function isAchromatic(color: OklchColor): boolean {
  return (
    color.c <= ACHROMATIC_EPS ||
    color.l <= ACHROMATIC_EPS ||
    color.l >= 1 - ACHROMATIC_EPS
  );
}

/**
 * Hue of an OKLCH color in HSL's hue scale (degrees). Used by the Hue slider
 * when the active format is `hsl` so the slider position matches what the
 * channel input shows — OKLCH hue and HSL hue diverge for the same color
 * (red is OKLCH ~29° but HSL 0°). Achromatic colors are read through
 * `ACHROMATIC_HUE_PROBE` so the slider tracks the stored hue.
 */
export function hslHue(color: OklchColor): number {
  const src = isAchromatic(color) ? { ...color, ...ACHROMATIC_HUE_PROBE } : color;
  const c = toHsl({ mode: "oklch", l: src.l, c: src.c, h: src.h });
  return c?.h ?? color.h;
}

/**
 * Hue of an OKLCH color in HSB/HSV's hue scale (degrees). See `hslHue`.
 */
export function hsbHue(color: OklchColor): number {
  const src = isAchromatic(color) ? { ...color, ...ACHROMATIC_HUE_PROBE } : color;
  const c = toHsv({ mode: "oklch", l: src.l, c: src.c, h: src.h });
  return c?.h ?? color.h;
}

/**
 * Map an output format to the gamut whose surface fills the picker's area
 * and bounds the lossless saturation-preserving updates from the hue and
 * lightness sliders. OKLCH and OKLab are unbounded as serialization formats
 * but still need a finite picking gamut — Rec.2020 covers everything that
 * fits inside `<canvas colorSpace="display-p3">` plus the slice that won't
 * paint accurately on any current monitor.
 */
export function gamutFromFormat(f: ColorFormat): Gamut {
  switch (f) {
    case "hex":
    case "rgb":
    case "hsl":
    case "hsb":
      return "srgb";
    case "p3":
      return "p3";
    case "oklch":
    case "oklab":
      return "rec2020";
  }
}

/**
 * Find the OKLCH "cusp" for a given hue and gamut — the (L, C) point of
 * maximum chroma. Used by the `hsv-sv` Area mode to map (S, V) onto a fully
 * gamut-filling square: V=1, S=1 lands on the cusp; V=1, S=0 is white;
 * V=0 is black.
 *
 * Two-stage search: a 32-step coarse sweep over L, then a 20-step fine sweep
 * around the best candidate. ~52 `findMaxChroma` calls per cusp lookup,
 * which is cheap enough to call once per repaint per active hue.
 */
export function findCusp(
  hDeg: number,
  gamut: Gamut,
): { l: number; c: number } {
  let bestL = 0.5;
  let bestC = 0;
  for (let i = 1; i < 32; i++) {
    const l = i / 32;
    const c = findMaxChroma(l, hDeg, gamut);
    if (c > bestC) {
      bestC = c;
      bestL = l;
    }
  }
  const lo = Math.max(0.001, bestL - 1 / 32);
  const hi = Math.min(0.999, bestL + 1 / 32);
  for (let i = 0; i <= 20; i++) {
    const l = lo + ((hi - lo) * i) / 20;
    const c = findMaxChroma(l, hDeg, gamut);
    if (c > bestC) {
      bestC = c;
      bestL = l;
    }
  }
  return { l: bestL, c: bestC };
}

export function gamutInfo(color: OklchColor): GamutInfo {
  const ok = { mode: "oklch" as const, ...oklchObj(color) };
  return {
    inSrgb: isInSrgb(ok),
    inP3: isInP3(ok),
    inRec2020: isInRec2020(ok),
  };
}

/**
 * Gamut-map an OKLCH color to the target gamut using CSS Color 4's
 * chroma-reduction-in-OKLCH algorithm (culori `toGamut`).
 * Returns canonical OKLCH form.
 */
export function toGamut(color: OklchColor, gamut: Gamut): OklchColor {
  return mapToGamutColor(color, gamut);
}

/**
 * sRGB channels of `color` after gamut mapping, clamped to [0, 1]. The
 * mapper tolerates `GAMUT_EPSILON` of channel slack, which near black is a
 * large relative error: fed to HSL/HSV it becomes saturation in the
 * millions. Channels are also snapped to 1e-6 (far below 8-bit precision):
 * near white HSL saturation divides by 1 - |2L - 1| ≈ 0, so 1e-7 of
 * conversion noise on #fff would otherwise read as 50%. HSL/HSV output
 * strings and channel readouts read through this.
 */
export function srgbClamped(color: OklchColor) {
  const rgb = toRgb({ mode: "oklch", ...oklchObj(mapToGamutColor(color, "srgb")) });
  const ch = (v: number | undefined) => round(clamp(v ?? 0, 0, 1), 6);
  return { mode: "rgb" as const, r: ch(rgb?.r), g: ch(rgb?.g), b: ch(rgb?.b) };
}

function mapToGamutColor(color: OklchColor, gamut: Gamut): OklchColor {
  const ok = { mode: "oklch" as const, ...oklchObj(color) };
  // culori's gamut test is strict, so an in-gamut color whose OKLCH
  // round-trip leaves a channel at -1e-14 gets chroma-reduced, nudging it
  // off its own value (#008197 → #018197). Honor the same tolerance as
  // `gamutInfo` and leave such colors untouched (`srgbClamped` clamps the
  // leftover slack for HSL/HSV output).
  const inGamut =
    gamut === "srgb" ? isInSrgb(ok) : gamut === "p3" ? isInP3(ok) : isInRec2020(ok);
  if (inGamut) return color;
  const targetMode = gamut === "srgb" ? "rgb" : gamut === "p3" ? "p3" : "rec2020";
  const mapper = culoriToGamut(targetMode, "oklch");
  const mapped = mapper(ok) as Color | undefined;
  if (!mapped) return color;
  const back = toOklch(mapped);
  if (!back) return color;
  return {
    l: back.l ?? color.l,
    c: Math.max(back.c ?? 0, 0),
    h: Number.isFinite(back.h) ? (back.h as number) : color.h,
    alpha: color.alpha,
  };
}

/**
 * Composite fg (with alpha) over an opaque bg in gamma-encoded sRGB — the
 * same space browsers blend in by default, so the result matches what's
 * painted.
 */
function compositeOnBg(fg: OklchColor, bg: OklchColor): OklchColor {
  if (fg.alpha >= 1) return fg;
  const fgRgb = toRgb({ mode: "oklch", ...oklchObj(fg) });
  const bgRgb = toRgb({ mode: "oklch", ...oklchObj(bg) });
  if (!fgRgb || !bgRgb) return fg;
  const a = fg.alpha;
  const out = {
    mode: "rgb" as const,
    r: (fgRgb.r ?? 0) * a + (bgRgb.r ?? 0) * (1 - a),
    g: (fgRgb.g ?? 0) * a + (bgRgb.g ?? 0) * (1 - a),
    b: (fgRgb.b ?? 0) * a + (bgRgb.b ?? 0) * (1 - a),
    alpha: 1,
  };
  const oklch = toOklch(out)!;
  return {
    l: oklch.l ?? 0,
    c: Math.max(oklch.c ?? 0, 0),
    h: Number.isFinite(oklch.h) ? (oklch.h as number) : 0,
    alpha: 1,
  };
}

export function contrast(fg: OklchColor, bg: OklchColor): ContrastResult {
  const composedFg = compositeOnBg(fg, { ...bg, alpha: 1 });
  const fgC = toRgb({ mode: "oklch", ...oklchObj(composedFg) });
  const bgC = toRgb({ mode: "oklch", ...oklchObj({ ...bg, alpha: 1 }) });
  const ratio = fgC && bgC ? wcagContrast(fgC, bgC) : 1;
  const safe = Number.isFinite(ratio) ? ratio : 1;
  return {
    wcag: round(safe, 2),
    wcagLevel: {
      aaNormal: safe >= 4.5,
      aaLarge: safe >= 3,
      aaaNormal: safe >= 7,
      aaaLarge: safe >= 4.5,
    },
    apca: apcaContrast(composedFg, { ...bg, alpha: 1 }),
  };
}

/**
 * APCA Lc value per APCA-W3 0.1.9 (SACAM 0.98G).
 * Reference: https://github.com/Myndex/SAPC-APCA
 * Returned value is rounded to 2 decimals.
 *  - Positive Lc: dark text on light bg
 *  - Negative Lc: light text on dark bg
 */
export function apcaContrast(fg: OklchColor, bg: OklchColor): number {
  const fgRgb = toRgb({ mode: "oklch", ...oklchObj(fg) });
  const bgRgb = toRgb({ mode: "oklch", ...oklchObj(bg) });
  if (!fgRgb || !bgRgb) return 0;

  const Ytxt = sapcLuminance(fgRgb.r ?? 0, fgRgb.g ?? 0, fgRgb.b ?? 0);
  const Ybg = sapcLuminance(bgRgb.r ?? 0, bgRgb.g ?? 0, bgRgb.b ?? 0);

  return round(sapcContrast(Ytxt, Ybg), 2);
}

const SA98G = {
  mainTRC: 2.4,
  Rco: 0.2126729,
  Gco: 0.7151522,
  Bco: 0.072175,
  normBG: 0.56,
  normTXT: 0.57,
  revTXT: 0.62,
  revBG: 0.65,
  blkThrs: 0.022,
  blkClmp: 1.414,
  scaleBoW: 1.14,
  scaleWoB: 1.14,
  loBoWoffset: 0.027,
  loWoBoffset: 0.027,
  deltaYmin: 0.0005,
  loClip: 0.1,
};

function sapcLuminance(r: number, g: number, b: number): number {
  const R = clamp(r, 0, 1) ** SA98G.mainTRC;
  const G = clamp(g, 0, 1) ** SA98G.mainTRC;
  const B = clamp(b, 0, 1) ** SA98G.mainTRC;
  return SA98G.Rco * R + SA98G.Gco * G + SA98G.Bco * B;
}

function sapcContrast(Ytxt: number, Ybg: number): number {
  const txt = Ytxt < SA98G.blkThrs
    ? Ytxt + (SA98G.blkThrs - Ytxt) ** SA98G.blkClmp
    : Ytxt;
  const bg = Ybg < SA98G.blkThrs
    ? Ybg + (SA98G.blkThrs - Ybg) ** SA98G.blkClmp
    : Ybg;

  if (Math.abs(bg - txt) < SA98G.deltaYmin) return 0;

  let SAPC = 0;
  let outputContrast = 0;

  if (bg > txt) {
    // Dark text on light background
    SAPC = (bg ** SA98G.normBG - txt ** SA98G.normTXT) * SA98G.scaleBoW;
    outputContrast = SAPC < SA98G.loClip ? 0 : SAPC - SA98G.loBoWoffset;
  } else {
    // Light text on dark background
    SAPC = (bg ** SA98G.revBG - txt ** SA98G.revTXT) * SA98G.scaleWoB;
    outputContrast = SAPC > -SA98G.loClip ? 0 : SAPC + SA98G.loWoBoffset;
  }

  return outputContrast * 100;
}

function clamp(x: number, lo: number, hi: number) {
  return Math.min(Math.max(x, lo), hi);
}

function round(x: number, dp: number) {
  const f = 10 ** dp;
  return Math.round(x * f) / f;
}

function wrapHue(h: number) {
  return ((h % 360) + 360) % 360;
}
registry/new-york/ui/fill-picker/lib/channels.ts
import { converter, type Color } from "culori";
import type { ColorFormat, OklchColor } from "./types";
import {
  ACHROMATIC_HUE_PROBE,
  findMaxChroma,
  gamutFromFormat,
  srgbClamped,
  toGamut,
} from "./color";

const toOklch = converter("oklch");
const toRgb = converter("rgb");
const toHsl = converter("hsl");
const toHsv = converter("hsv");
const toOklab = converter("oklab");
const toP3 = converter("p3");

export interface ChannelDescriptor {
  /** Internal key used by setColorChannel. */
  key: string;
  /** One- or two-letter UI label (R, G, B, H, S, L, etc.). */
  label: string;
  /** Current value in display units (e.g. RGB 0–255, OKLCH L 0–100). */
  value: number;
  min: number;
  /** Hard edit bound; `Infinity` for channels unbounded in their space
   * (OKLCH chroma). Clamping inputs must skip non-finite bounds. */
  max: number;
  /** Arrow-key step. */
  step: number;
  /** Shift+arrow step. */
  bigStep: number;
  /** Decimals to render. */
  precision: number;
  /** Optional unit suffix shown after the field. */
  suffix?: string;
}

const ALPHA_DESCRIPTOR = (alpha: number): ChannelDescriptor => ({
  key: "alpha",
  label: "α",
  value: round(alpha * 100, 0),
  min: 0,
  max: 100,
  step: 1,
  bigStep: 10,
  precision: 0,
  suffix: "%",
});

/**
 * Per-format channel descriptors for the multi-field input. Hex returns an
 * empty array — render hex as a single text field instead.
 */
export function colorChannels(
  color: OklchColor,
  format: ColorFormat,
): ChannelDescriptor[] {
  switch (format) {
    case "hex":
      return [];
    case "rgb": {
      const rgb = toRgb({
        mode: "oklch",
        ...oklchObj(toGamut(color, "srgb")),
      });
      const r = round((rgb?.r ?? 0) * 255, 0);
      const g = round((rgb?.g ?? 0) * 255, 0);
      const b = round((rgb?.b ?? 0) * 255, 0);
      return [
        intChannel("r", "R", r, 0, 255),
        intChannel("g", "G", g, 0, 255),
        intChannel("b", "B", b, 0, 255),
        ALPHA_DESCRIPTOR(color.alpha),
      ];
    }
    case "hsl": {
      const hsl = toHsl(srgbClamped(color));
      return [
        intChannel("h", "H", round(stableFormatHue(toHsl, color, hsl?.h), 0), 0, 360),
        intChannel("s", "S", round((hsl?.s ?? 0) * 100, 0), 0, 100, "%"),
        intChannel("l", "L", round((hsl?.l ?? 0) * 100, 0), 0, 100, "%"),
        ALPHA_DESCRIPTOR(color.alpha),
      ];
    }
    case "hsb": {
      const hsv = toHsv(srgbClamped(color));
      return [
        intChannel("h", "H", round(stableFormatHue(toHsv, color, hsv?.h), 0), 0, 360),
        intChannel("s", "S", round((hsv?.s ?? 0) * 100, 0), 0, 100, "%"),
        intChannel("b", "B", round((hsv?.v ?? 0) * 100, 0), 0, 100, "%"),
        ALPHA_DESCRIPTOR(color.alpha),
      ];
    }
    case "oklch":
      return [
        intChannel("l", "L", round(color.l * 100, 0), 0, 100, "%"),
        // Chroma is unbounded above at edit time (gamut limits apply at
        // display via toGamut) — a finite max here would let clamping
        // number fields destroy wide-gamut values.
        floatChannel("c", "C", round(color.c, 3), 0, Infinity, 0.005, 0.05, 3),
        intChannel("h", "H", round(color.h, 0), 0, 360),
        ALPHA_DESCRIPTOR(color.alpha),
      ];
    case "oklab": {
      const lab = toOklab({ mode: "oklch", ...oklchObj(color) });
      return [
        intChannel("l", "L", round((lab?.l ?? color.l) * 100, 0), 0, 100, "%"),
        floatChannel("a", "a", round(lab?.a ?? 0, 3), -0.5, 0.5, 0.005, 0.05, 3),
        floatChannel("b", "b", round(lab?.b ?? 0, 3), -0.5, 0.5, 0.005, 0.05, 3),
        ALPHA_DESCRIPTOR(color.alpha),
      ];
    }
    case "p3": {
      const p3 = toP3({
        mode: "oklch",
        ...oklchObj(toGamut(color, "p3")),
      });
      return [
        floatChannel("r", "R", round(p3?.r ?? 0, 3), 0, 1, 0.01, 0.1, 3),
        floatChannel("g", "G", round(p3?.g ?? 0, 3), 0, 1, 0.01, 0.1, 3),
        floatChannel("b", "B", round(p3?.b ?? 0, 3), 0, 1, 0.01, 0.1, 3),
        ALPHA_DESCRIPTOR(color.alpha),
      ];
    }
  }
}

/**
 * Replace one channel's value in the active format's space and convert the
 * result back to canonical OKLCH. Display-unit input (e.g. RGB 0–255, OKLCH
 * L 0–100, alpha 0–100) — colorChannels and this writer agree on units.
 */
export function setColorChannel(
  color: OklchColor,
  format: ColorFormat,
  key: string,
  value: number,
): OklchColor {
  // A cleared or garbage numeric field can surface as NaN; storing it would
  // poison every downstream conversion.
  if (!Number.isFinite(value)) return color;
  if (key === "alpha") {
    return { ...color, alpha: clamp(value / 100, 0, 1) };
  }
  switch (format) {
    case "hex":
      // Hex isn't channel-addressable; callers should special-case it.
      return color;
    case "rgb": {
      const rgb = toRgb({
        mode: "oklch",
        ...oklchObj(toGamut(color, "srgb")),
      }) ?? { r: 0, g: 0, b: 0 };
      const next = {
        ...rgb,
        [key]: clamp(value / 255, 0, 1),
        mode: "rgb" as const,
      };
      return fromCulori(next, color.alpha, color.h);
    }
    case "hsl": {
      const hsl = toHsl({
        mode: "oklch",
        ...oklchObj(toGamut(color, "srgb")),
      }) ?? { h: 0, s: 0, l: 0 };
      if (key === "h" && isAchromatic(color.l, color.c)) {
        return { ...color, h: oklchHueForFormatHue("hsl", wrap(value, 360), color.h) };
      }
      const h = key === "h" ? wrap(value, 360) : stableFormatHue(toHsl, color, hsl.h);
      const s = key === "s" ? clamp(value / 100, 0, 1) : hsl.s;
      const l = key === "l" ? clamp(value / 100, 0, 1) : hsl.l;
      return fromCulori({ mode: "hsl" as const, h, s, l }, color.alpha, color.h);
    }
    case "hsb": {
      const hsv = toHsv({
        mode: "oklch",
        ...oklchObj(toGamut(color, "srgb")),
      }) ?? { h: 0, s: 0, v: 0 };
      if (key === "h" && isAchromatic(color.l, color.c)) {
        return { ...color, h: oklchHueForFormatHue("hsv", wrap(value, 360), color.h) };
      }
      const h = key === "h" ? wrap(value, 360) : stableFormatHue(toHsv, color, hsv.h);
      const s = key === "s" ? clamp(value / 100, 0, 1) : hsv.s;
      const v = key === "b" ? clamp(value / 100, 0, 1) : hsv.v;
      return fromCulori({ mode: "hsv" as const, h, s, v }, color.alpha, color.h);
    }
    case "oklch": {
      switch (key) {
        case "l":
          return { ...color, l: clamp(value / 100, 0, 1) };
        case "c":
          // OKLCH chroma is unbounded above; gamut limits are applied at
          // display time via toGamut, never at channel-edit time.
          return { ...color, c: Math.max(value, 0) };
        case "h":
          return { ...color, h: wrap(value, 360) };
        default:
          return color;
      }
    }
    case "oklab": {
      const lab = toOklab({ mode: "oklch", ...oklchObj(color) }) ?? {
        l: 0,
        a: 0,
        b: 0,
      };
      const l = key === "l" ? clamp(value / 100, 0, 1) : (lab.l ?? 0);
      const a = key === "a" ? clamp(value, -0.5, 0.5) : (lab.a ?? 0);
      const b = key === "b" ? clamp(value, -0.5, 0.5) : (lab.b ?? 0);
      return fromCulori({ mode: "oklab" as const, l, a, b }, color.alpha, color.h);
    }
    case "p3": {
      const p3 = toP3({
        mode: "oklch",
        ...oklchObj(toGamut(color, "p3")),
      }) ?? { r: 0, g: 0, b: 0 };
      const next = {
        ...p3,
        [key]: clamp(value, 0, 1),
        mode: "p3" as const,
      };
      return fromCulori(next, color.alpha, color.h);
    }
  }
}

/**
 * Next color for a hue-slider commit. Shared by both `<ColorPicker.Hue>`
 * variants (classic and Base UI) so the two can't drift apart.
 *
 * Two paths:
 *   - HSL/HSB — write the hue through the active format so the channel
 *     input's H matches the slider exactly (no OKLCH↔HSL hue drift). On an
 *     achromatic color this stores the latent OKLCH hue that reads back as
 *     `newHue` (see `oklchHueForFormatHue`).
 *   - everything else — rescale chroma to preserve "saturation", i.e. the
 *     color's chroma as a fraction of the max chroma available at
 *     (l, hue, gamut). Max chroma moves with hue (green has far less than
 *     red in P3), so preserving *absolute* chroma would walk the color out
 *     of the active gamut as the user scrolls. Preserving the ratio keeps
 *     the area bead's X position — and the gamut badge — put.
 *
 * `newHue` may be any real number; it is wrapped into [0, 360). A
 * non-finite `newHue` returns `color` unchanged.
 */
export function setHueFromSlider(
  color: OklchColor,
  newHue: number,
  format: ColorFormat,
): OklchColor {
  if (!Number.isFinite(newHue)) return color;
  const wrapped = wrap(newHue, 360);
  if (format === "hsl" || format === "hsb") {
    return setColorChannel(color, format, "h", wrapped);
  }
  const gamut = gamutFromFormat(format);
  const oldMaxC = findMaxChroma(color.l, color.h, gamut);
  const newMaxC = findMaxChroma(color.l, wrapped, gamut);
  const saturation = oldMaxC > 1e-6 ? color.c / oldMaxC : 0;
  return { ...color, h: wrapped, c: saturation * newMaxC };
}

const ACHROMATIC_EPS = 1e-4;

function isAchromatic(l: number, c: number): boolean {
  return (
    c <= ACHROMATIC_EPS || l <= ACHROMATIC_EPS || l >= 1 - ACHROMATIC_EPS
  );
}

/**
 * Convert an edited culori color back to canonical OKLCH, preserving
 * `fallbackHue` (the pre-edit hue) when the result is achromatic — there
 * culori's hue is undefined or numerically meaningless, and storing it
 * would destroy the user's hue (e.g. HSL s → 0 → 50 snapping blue to red).
 * Chroma is the only axis allowed to be lossy; hue must round-trip.
 */
function fromCulori(c: Color, alpha: number, fallbackHue: number): OklchColor {
  const ok = toOklch(c);
  if (!ok) return { l: 0, c: 0, h: fallbackHue, alpha };
  const chroma = Math.max(ok.c ?? 0, 0);
  const l = ok.l ?? 0;
  return {
    l,
    c: chroma,
    h:
      isAchromatic(l, chroma) || !Number.isFinite(ok.h)
        ? fallbackHue
        : (ok.h as number),
    alpha,
  };
}

/**
 * Hue of `color` in the target format's own hue scale (HSL/HSV degrees),
 * stable at the achromatic point: when chroma is ~0 the direct conversion
 * yields an undefined or garbage hue, so probe a slightly-saturated color
 * on the same OKLCH hue instead. Keeps the H field and re-saturation edits
 * anchored to the hue the user last had.
 */
function stableFormatHue(
  convert: (c: Color) => { h?: number } | undefined,
  color: OklchColor,
  raw: number | undefined,
): number {
  if (!isAchromatic(color.l, color.c) && Number.isFinite(raw)) {
    return raw as number;
  }
  const probe = convert({ mode: "oklch", ...ACHROMATIC_HUE_PROBE, h: color.h });
  return probe?.h ?? color.h;
}

/**
 * Inverse of the achromatic probe in `stableFormatHue`: the OKLCH hue whose
 * probe color reads `target` on the HSL/HSV hue scale. An achromatic color
 * has no format hue to write through, so an H edit stores this latent OKLCH
 * hue instead — otherwise the edit is a silent no-op. Fixed-point iteration:
 * keep the probe's s/l (or s/v) and swap in the target hue; converges to
 * well under 0.01° in a few steps because s/l barely move with hue.
 */
function oklchHueForFormatHue(
  mode: "hsl" | "hsv",
  target: number,
  guess: number,
): number {
  let h = guess;
  for (let i = 0; i < 6; i++) {
    const probe = { mode: "oklch" as const, ...ACHROMATIC_HUE_PROBE, h };
    const back =
      mode === "hsl"
        ? toOklch({ ...toHsl(probe), h: target })
        : toOklch({ ...toHsv(probe), h: target });
    if (!back || !Number.isFinite(back.h)) break;
    h = wrap(back.h as number, 360);
  }
  return h;
}

function oklchObj(c: OklchColor) {
  return { l: c.l, c: c.c, h: c.h, alpha: c.alpha };
}

function intChannel(
  key: string,
  label: string,
  value: number,
  min: number,
  max: number,
  suffix?: string,
): ChannelDescriptor {
  return {
    key,
    label,
    value,
    min,
    max,
    step: 1,
    bigStep: 10,
    precision: 0,
    suffix,
  };
}

function floatChannel(
  key: string,
  label: string,
  value: number,
  min: number,
  max: number,
  step: number,
  bigStep: number,
  precision: number,
  suffix?: string,
): ChannelDescriptor {
  return { key, label, value, min, max, step, bigStep, precision, suffix };
}

function clamp(v: number, min: number, max: number) {
  return v < min ? min : v > max ? max : v;
}

function wrap(v: number, mod: number) {
  return ((v % mod) + mod) % mod;
}

function round(v: number, precision: number) {
  const m = 10 ** precision;
  return Math.round(v * m) / m;
}
registry/new-york/ui/fill-picker/lib/constants.ts
/**
 * Shared alpha-transparency checkerboard backgrounds (inline SVG data URIs).
 * Two sizes on purpose: 12px tiles for large surfaces (alpha slider, preview,
 * gradient area/overlay), 8px for compact chips (swatches, stop rows,
 * contrast readout). Pair the SM variant with `backgroundSize: "8px 8px"`.
 */
export const CHECKERBOARD_LG =
  "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>\")";

export const CHECKERBOARD_SM =
  "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>\")";

/**
 * Edge treatment for sample chips — swatches, gradient presets, the preview.
 *
 * These used to carry `border border-border`, which is wrong for a color
 * sample in two ways: an outer border steals a pixel from the color itself,
 * and because it paints a fixed opaque grey *outside* the sample it reads as
 * a halo — glaring around a dark chip on a light surface, and vanishing
 * entirely around a dark chip on a dark surface, which is exactly backwards
 * from where an edge is needed.
 *
 * Instead draw a translucent hairline *inside* the chip, as an `::after`
 * overlay so it sits above whatever fills the tile (the swatch's color span,
 * the preview's stacked fg/bg layers). Being translucent, it disappears into
 * a saturated or dark color — which is correct, since those already separate
 * from the surface — and only asserts itself on the pale samples that would
 * otherwise dissolve into it. The tone flips per theme so a near-white chip
 * stays defined on a light surface and a near-black chip on a dark one.
 *
 * Apply to a `relative` element; the chip's own `rounded-*` is inherited.
 */
export const SAMPLE_EDGE =
  "after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:content-[''] " +
  "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)]";
registry/new-york/ui/fill-picker/lib/swatch-presets.ts
import type { OklchColor } from "./types";

/** Default swatch presets shared by both Swatches variants. */
export const DEFAULT_SWATCH_PRESETS = [
  "oklch(0.95 0 0)",
  "oklch(0.75 0 0)",
  "oklch(0.5 0 0)",
  "oklch(0.25 0 0)",
  "oklch(0.05 0 0)",
  "oklch(0.7 0.18 30)",
  "oklch(0.7 0.18 90)",
  "oklch(0.7 0.18 150)",
  "oklch(0.7 0.18 210)",
  "oklch(0.7 0.18 270)",
];

/**
 * Compare a preset to the current color in canonical OKLCH form so the
 * active ring shows regardless of the active output format (a hex-string
 * comparison would never match when the format is anything but `hex`).
 * Achromatic colors have an undefined hue; the hue check is skipped when
 * either side has near-zero chroma so swatches like `oklch(0.5 0 0)` match
 * the current gray regardless of its drifted hue.
 */
export function isSameSwatchColor(
  preset: OklchColor,
  color: OklchColor,
): boolean {
  if (Math.abs(preset.l - color.l) >= 1e-3) return false;
  if (Math.abs(preset.c - color.c) >= 1e-3) return false;
  if (Math.abs(preset.alpha - color.alpha) >= 1e-3) return false;
  if (preset.c < 1e-3 || color.c < 1e-3) return true;
  const d = (((preset.h - color.h) % 360) + 360) % 360;
  const wrapped = d > 180 ? 360 - d : d;
  return wrapped < 0.1;
}
registry/new-york/ui/fill-picker/hooks/use-color-picker.ts
"use client";

import * as React from "react";
import {
  parseColor,
  parseColorDetailed,
  formatAll,
  gamutFromFormat,
  gamutInfo,
  contrast,
  toGamut,
} from "../lib/color";
import type {
  ColorFormat,
  ContrastResult,
  GamutInfo,
  OklchColor,
} from "../lib/types";

export type ColorComponent = "l" | "c" | "h" | "alpha";

export interface UseColorPickerProps {
  /** Controlled color value (string or canonical OklchColor). */
  value?: string | OklchColor;
  /** Initial color when uncontrolled. */
  defaultValue?: string | OklchColor;
  /**
   * Fires whenever the color changes.
   *  - `color`: canonical OKLCH form (lossless source of truth).
   *  - `formatted`: the active output format string (governed by `format`).
   *  - `formats`: every supported format pre-serialized — use `formats.hex`
   *    when you need a fallback alongside the canonical `formats.oklch`.
   */
  onValueChange?: (
    color: OklchColor,
    formatted: string,
    formats: Record<ColorFormat, string>,
  ) => void;
  /** Active output format. */
  format?: ColorFormat;
  /** Initial format when uncontrolled. */
  defaultFormat?: ColorFormat;
  onFormatChange?: (format: ColorFormat) => void;
  /**
   * Color spaces (output formats) the picker exposes. Restricts the
   * FormatSwitcher tabs and the default format. Defaults to all formats.
   */
  formats?: ColorFormat[];
  /** Background used for contrast metrics. */
  backgroundColor?: string | OklchColor;
}

export interface ColorPickerState {
  color: OklchColor;
  format: ColorFormat;
  /** Active format string (mirrors `format`). */
  formatted: string;
  /** Allowed output formats (the set restricting the FormatSwitcher). */
  formats: ColorFormat[];
  /** Pre-serialized strings for every supported format. */
  formatStrings: Record<ColorFormat, string>;
  setColor: (next: string | OklchColor) => void;
  setComponent: (key: ColorComponent, value: number) => void;
  adjustComponent: (key: ColorComponent, delta: number) => void;
  setFormat: (f: ColorFormat) => void;
  setFromString: (s: string) => boolean;
  gamut: GamutInfo;
  contrast: ContrastResult;
  background: OklchColor;
}

const ALL_FORMATS: ColorFormat[] = ["hex", "rgb", "hsl", "hsb", "oklch", "oklab", "p3"];

const BLACK: OklchColor = { l: 0, c: 0, h: 0, alpha: 1 };
const WHITE: OklchColor = { l: 1, c: 0, h: 0, alpha: 1 };

function coerce(input: string | OklchColor | undefined, fallback: OklchColor): OklchColor {
  if (!input) return fallback;
  if (typeof input === "string") {
    return parseColor(input) ?? fallback;
  }
  // Strings are hue-wrapped by the parser; normalize objects the same way.
  return { ...input, h: wrapHue(input.h) };
}

function clamp(x: number, lo: number, hi: number) {
  return Math.min(Math.max(x, lo), hi);
}

function wrapHue(h: number) {
  const m = h % 360;
  return m < 0 ? m + 360 : m;
}

const HUE_EPS = 1e-4;
function isAchromatic(c: OklchColor): boolean {
  return c.c <= HUE_EPS || c.l <= HUE_EPS || c.l >= 1 - HUE_EPS;
}

function applyComponent(c: OklchColor, key: ColorComponent, raw: number): OklchColor {
  // NaN/Infinity (e.g. a cleared number field) would poison every derived
  // conversion; ignore the write instead.
  if (!Number.isFinite(raw)) return c;
  switch (key) {
    case "l":
      return { ...c, l: clamp(raw, 0, 1) };
    case "c":
      return { ...c, c: Math.max(raw, 0) };
    case "h":
      return { ...c, h: wrapHue(raw) };
    case "alpha":
      return { ...c, alpha: clamp(raw, 0, 1) };
  }
}

export function useColorPicker(props: UseColorPickerProps = {}): ColorPickerState {
  const {
    value: controlledValue,
    defaultValue,
    onValueChange,
    format: controlledFormat,
    defaultFormat = "p3",
    onFormatChange,
    formats: formatsProp,
    backgroundColor,
  } = props;

  const formats = React.useMemo<ColorFormat[]>(
    () => (formatsProp && formatsProp.length > 0 ? formatsProp : ALL_FORMATS),
    [formatsProp],
  );
  const initialFormat = formats.includes(defaultFormat) ? defaultFormat : formats[0];

  const [internalColor, setInternalColor] = React.useState<OklchColor>(() =>
    coerce(defaultValue, BLACK),
  );
  const [internalFormat, setInternalFormat] = React.useState<ColorFormat>(initialFormat);

  const isControlledColor = controlledValue !== undefined;
  const isControlledFormat = controlledFormat !== undefined;

  // Hue is undefined for achromatic colors (c=0, pure black, pure white) so
  // any string round-trip through hex/rgb erases it. Remember the last hue
  // observed on a chromatic, mid-lightness color and substitute it back when
  // the resolved color lands on an achromatic edge — keeps the area picker
  // from snapping the hue to 0 when the user drags toward gray/black/white.
  // Seeded from the initial color state rather than re-parsing
  // `defaultValue` on every render (every drag frame).
  const lastGoodHueRef = React.useRef<number>(internalColor.h || 0);

  const isControlledStringInput =
    isControlledColor && typeof controlledValue === "string";
  const controlledParsed = React.useMemo(
    () =>
      isControlledStringInput
        ? parseColorDetailed(controlledValue as string)
        : null,
    [controlledValue, isControlledStringInput],
  );
  const rawColor = React.useMemo(
    () =>
      isControlledStringInput
        ? (controlledParsed?.color ?? BLACK)
        : isControlledColor
          ? coerce(controlledValue, BLACK)
          : internalColor,
    [
      controlledParsed,
      controlledValue,
      internalColor,
      isControlledColor,
      isControlledStringInput,
    ],
  );
  // A hue is "good" (worth remembering) when it's meaningful: any chromatic
  // color, or a string that authored a hue even at zero chroma (OKLCH can).
  const controlledHueAuthored = controlledParsed
    ? !controlledParsed.hueMissing
    : false;
  React.useLayoutEffect(() => {
    if (!isAchromatic(rawColor) || controlledHueAuthored) {
      lastGoodHueRef.current = rawColor.h;
    }
  }, [controlledHueAuthored, rawColor]);
  // Only substitute on string-controlled inputs whose parse actually lost
  // the hue (achromatic hex/rgb/hsl round-trips). Strings that author a hue
  // (e.g. `oklch(0.5 0 90)`), object-controlled, and uncontrolled state all
  // carry the hue verbatim — including explicit user assignments like
  // setComponent("h", 0) on a black/white color.
  // Reading the prior committed hue is intentional: layout effects keep the
  // ref current before input can arrive, while avoiding a render-time write.
  // eslint-disable-next-line react-hooks/refs
  const preservedHue = lastGoodHueRef.current;
  const color = React.useMemo<OklchColor>(
    () =>
      isControlledStringInput && (controlledParsed?.hueMissing ?? true)
        ? { ...rawColor, h: preservedHue }
        : rawColor,
    [
      controlledParsed?.hueMissing,
      isControlledStringInput,
      preservedHue,
      rawColor,
    ],
  );
  const format = isControlledFormat ? controlledFormat! : internalFormat;
  const background = React.useMemo(
    () => coerce(backgroundColor, WHITE),
    [backgroundColor],
  );

  const formatStrings = React.useMemo(() => formatAll(color), [color]);
  const formatted = formatStrings[format];
  const gamut = React.useMemo(() => gamutInfo(color), [color]);
  const contrastResult = React.useMemo(
    () => contrast(color, background),
    [color, background],
  );

  const commitColor = React.useCallback(
    (next: OklchColor, formatOverride?: ColorFormat) => {
      // NaN never equals itself, so storing it would spin the render-time
      // sync in consumers; drop non-finite colors at the boundary.
      if (![next.l, next.c, next.h, next.alpha].every(Number.isFinite)) return;
      if (!isControlledColor) setInternalColor(next);
      if (onValueChange) {
        const all = formatAll(next);
        onValueChange(next, all[formatOverride ?? format], all);
      }
    },
    [format, isControlledColor, onValueChange],
  );

  // Commit a string input, re-pinning the remembered hue when the parse
  // lost it (achromatic hex/rgb/hsl) — otherwise a gray commit would store
  // the defaulted h: 0 and later re-saturation would snap to red.
  const commitString = React.useCallback(
    (s: string): boolean => {
      const parsed = parseColorDetailed(s);
      if (!parsed) return false;
      const next = parsed.hueMissing
        ? { ...parsed.color, h: lastGoodHueRef.current }
        : parsed.color;
      commitColor(next);
      return true;
    },
    [commitColor],
  );

  const setColor = React.useCallback(
    (next: string | OklchColor) => {
      if (typeof next === "string") {
        commitString(next);
        return;
      }
      commitColor(next);
    },
    [commitColor, commitString],
  );

  const setComponent = React.useCallback(
    (key: ColorComponent, val: number) => {
      commitColor(applyComponent(color, key, val));
    },
    [color, commitColor],
  );

  const adjustComponent = React.useCallback(
    (key: ColorComponent, delta: number) => {
      const current =
        key === "l" ? color.l : key === "c" ? color.c : key === "h" ? color.h : color.alpha;
      commitColor(applyComponent(color, key, current + delta));
    },
    [color, commitColor],
  );

  const setFormat = React.useCallback(
    (f: ColorFormat) => {
      // Switching formats is also a switch of *picking* gamut. If the current
      // OKLCH state lives outside the new format's gamut (e.g. user authored
      // a wide P3 chroma in OKLCH mode, then toggled to hex), the displayed
      // string would be gamut-mapped at format time but the underlying state
      // — and the gamut badge — would still report out-of-gamut. Clamp on
      // the way in so state and display agree. Hue is pinned per the picker's
      // "chroma is the only lossy axis" invariant.
      const targetGamut = gamutFromFormat(f);
      const info = gamutInfo(color);
      const alreadyIn =
        targetGamut === "srgb"
          ? info.inSrgb
          : targetGamut === "p3"
            ? info.inP3
            : info.inRec2020;
      if (!alreadyIn) {
        const targetHue = color.h;
        const clamped = toGamut(color, targetGamut);
        // Pass the next format explicitly so the clamp notification never
        // emits using the closure's previous format.
        commitColor({ ...clamped, h: targetHue }, f);
      }
      if (!isControlledFormat) setInternalFormat(f);
      onFormatChange?.(f);
    },
    [color, commitColor, isControlledFormat, onFormatChange],
  );

  const setFromString = React.useCallback(
    (s: string) => commitString(s),
    [commitString],
  );

  return {
    color,
    format,
    formatted,
    formats,
    formatStrings,
    setColor,
    setComponent,
    adjustComponent,
    setFormat,
    setFromString,
    gamut,
    contrast: contrastResult,
    background,
  };
}
registry/new-york/ui/fill-picker/context.tsx
"use client";

import * as React from "react";
import type { ColorPickerState } from "./hooks/use-color-picker";

export const ColorPickerContext = React.createContext<ColorPickerState | null>(null);

export function useColorPickerContext(): ColorPickerState {
  const ctx = React.useContext(ColorPickerContext);
  if (!ctx) {
    throw new Error(
      "ColorPicker.* parts must be rendered inside <ColorPicker> (or <ColorPicker.Root>)",
    );
  }
  return ctx;
}
registry/new-york/ui/fill-picker/parts/root.tsx
"use client";

import * as React from "react";
import { ColorPickerContext } from "../context";
import { useColorPicker, type UseColorPickerProps } from "../hooks/use-color-picker";
import { cn } from "@/lib/utils";

export interface RootProps
  extends UseColorPickerProps,
    Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
  /** When true the picker is rendered without children using the default layout. */
  asChild?: boolean;
}

export const Root = React.forwardRef<HTMLDivElement, RootProps>(function Root(
  {
    value,
    defaultValue,
    onValueChange,
    format,
    defaultFormat,
    onFormatChange,
    formats,
    backgroundColor,
    className,
    children,
    ...rest
  },
  ref,
) {
  const state = useColorPicker({
    value,
    defaultValue,
    onValueChange,
    format,
    defaultFormat,
    onFormatChange,
    formats,
    backgroundColor,
  });

  return (
    <ColorPickerContext.Provider value={state}>
      <div
        ref={ref}
        data-slot="color-picker"
        className={cn(
          "flex w-full max-w-[280px] flex-col gap-2 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-sm",
          className,
        )}
        {...rest}
      >
        {children}
      </div>
    </ColorPickerContext.Provider>
  );
});
registry/new-york/ui/fill-picker/parts/field.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/**
 * Bordered, h-8 shell every multi-field input inside the picker shares.
 * Visual source of truth for `<ColorPicker.ChannelInput>`, the gradient
 * angle / position / radius / ellipse-radii / stop-position inputs, and
 * both CSS-string inputs. Owning the shell here means a single edit
 * here re-themes every text input in the package.
 */
export const FieldShell = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function FieldShell({ className, ...rest }, ref) {
  return (
    <div
      ref={ref}
      className={cn(
        "flex h-8 items-stretch overflow-hidden rounded-md border border-input bg-transparent font-mono text-xs shadow-xs",
        "focus-within:ring-1 focus-within:ring-ring",
        className,
      )}
      {...rest}
    />
  );
});

/** Vertical 1px divider between fields inside a `FieldShell`. */
export function FieldDivider() {
  return <div aria-hidden className="w-px self-stretch bg-border" />;
}

/**
 * Borderless input that lives inside a `FieldShell`. Defaults match the
 * channel-input numeric field: full width of its slot, right-aligned
 * tabular digits, transparent background. Pass `className` to override
 * per use (e.g. left-align for hex / CSS strings).
 *
 * When `nudge` is set, ↑/↓ step the numeric value by `nudge` (Shift × 10).
 * Implemented by dispatching a native input event so the consumer's
 * existing `onChange` handler runs unchanged — no parallel commit path,
 * no double validation. Non-numeric values are ignored.
 */
export interface FieldInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  /**
   * Step amount for ↑/↓ keyboard nudge. Shift multiplies by 10. Omit
   * to disable nudge (text fields and non-numeric inputs).
   */
  nudge?: number;
}

export const FieldInput = React.forwardRef<HTMLInputElement, FieldInputProps>(
  function FieldInput({ className, type = "text", nudge, onKeyDown, ...rest }, ref) {
    return (
      <input
        ref={ref}
        type={type}
        spellCheck={false}
        autoComplete="off"
        autoCorrect="off"
        autoCapitalize="off"
        onKeyDown={(e) => {
          if (nudge && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
            e.preventDefault();
            // Treat empty / unparseable input (e.g. RadiusInput in its
            // "auto" placeholder state) as 0 so the first arrow press
            // commits a real numeric value instead of doing nothing.
            const parsed = parseFloat(e.currentTarget.value);
            const cur = Number.isFinite(parsed) ? parsed : 0;
            const delta =
              (e.key === "ArrowUp" ? 1 : -1) * (e.shiftKey ? nudge * 10 : nudge);
            const next = cur + delta;
            // Use the native value setter so React's synthetic onChange
            // fires — directly assigning `.value` is swallowed by React's
            // controlled-input tracker.
            const setter = Object.getOwnPropertyDescriptor(
              window.HTMLInputElement.prototype,
              "value",
            )?.set;
            setter?.call(e.currentTarget, String(next));
            e.currentTarget.dispatchEvent(new Event("input", { bubbles: true }));
          }
          onKeyDown?.(e);
        }}
        className={cn(
          "w-full min-w-0 bg-transparent px-1.5 text-right outline-none tabular-nums",
          className,
        )}
        {...rest}
      />
    );
  },
);

export interface FieldDraftInputProps
  extends Omit<FieldInputProps, "value" | "defaultValue" | "onChange"> {
  /** Canonical display string; re-seeds the draft whenever it changes. */
  value: string;
  /**
   * Called with the raw draft on Enter, on blur after an edit, and on each
   * ↑/↓ nudge. The consumer parses, clamps and writes; an unparseable or
   * clamped commit just snaps the draft back to `value`.
   */
  onCommit: (raw: string) => void;
}

/**
 * `FieldInput` with a local draft: keystrokes only edit the text, and the
 * value is committed on Enter / blur (Escape reverts). Committing on every
 * keystroke makes multi-digit and decimal entry impossible when the parsed
 * prefix gets clamped or normalized ("4" of "400" wraps, "" can't be typed).
 * ↑/↓ nudges (via `nudge`) still commit immediately.
 */
export const FieldDraftInput = React.forwardRef<
  HTMLInputElement,
  FieldDraftInputProps
>(function FieldDraftInput(
  { value, onCommit, onBlur, onKeyDown, nudge, ...rest },
  ref,
) {
  const [draft, setDraft] = React.useState(value);
  const [dirty, setDirty] = React.useState(false);
  // Same in-render re-seed as `ChannelField` / `HexField`.
  const [prevValue, setPrevValue] = React.useState(value);
  if (value !== prevValue) {
    setPrevValue(value);
    setDraft(value);
    setDirty(false);
  }

  const commit = (raw: string) => {
    onCommit(raw);
    // Snap back to the canonical string. If the commit changed it, the
    // re-seed above overwrites this on the next render anyway; if it didn't
    // (clamped to the same value, unparseable), this is what clears "999".
    setDraft(value);
    setDirty(false);
  };

  return (
    <FieldInput
      ref={ref}
      nudge={nudge}
      value={draft}
      onChange={(e) => {
        setDraft(e.target.value);
        setDirty(true);
      }}
      onBlur={(e) => {
        if (dirty) commit(e.currentTarget.value);
        onBlur?.(e);
      }}
      onKeyDown={(e) => {
        if (e.key === "Enter") {
          e.preventDefault();
          if (dirty) commit(e.currentTarget.value);
        } else if (e.key === "Escape") {
          setDraft(value);
          setDirty(false);
        } else if (nudge && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
          // FieldInput already wrote the nudged number into the input.
          commit(e.currentTarget.value);
        }
        onKeyDown?.(e);
      }}
      {...rest}
    />
  );
});

/**
 * Flex slot that pairs a `FieldInput` with an optional `FieldSuffix`
 * (the muted `°`, `%`, `px`, `×` glyph). Renders as a `<label>` so the
 * suffix is part of the clickable hit area but doesn't steal focus.
 */
export const FieldInputGroup = React.forwardRef<
  HTMLLabelElement,
  React.LabelHTMLAttributes<HTMLLabelElement>
>(function FieldInputGroup({ className, ...rest }, ref) {
  return (
    <label
      ref={ref}
      className={cn(
        "relative inline-flex h-full min-w-0 flex-1 items-center justify-end",
        className,
      )}
      {...rest}
    />
  );
});

/** Muted, non-interactive suffix label (°, %, px, ×). */
export const FieldSuffix = React.forwardRef<
  HTMLSpanElement,
  React.HTMLAttributes<HTMLSpanElement>
>(function FieldSuffix({ className, ...rest }, ref) {
  return (
    <span
      ref={ref}
      aria-hidden
      className={cn(
        "pointer-events-none pr-1.5 text-muted-foreground",
        className,
      )}
      {...rest}
    />
  );
});
registry/new-york/ui/fill-picker/parts/area.tsx
"use client";

import * as React from "react";
import { useColorPickerContext } from "../context";
import {
  findCusp,
  findMaxChroma,
  formatColor,
  gamutFromFormat as libGamutFromFormat,
  gamutSignedDistance,
  linSrgbToLinP3,
  oklchToLinearSrgb,
  toGamut,
} from "../lib/color";
import type { Gamut, OklchColor } from "../lib/types";
import { useLiveAnnounce } from "./use-live-announce";
import { cn } from "@/lib/utils";

export type AreaMode = "oklch-cl" | "hsv-sv" | "oklch-hc";
export type AreaGamut = Gamut | "none";

export interface AreaProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * What the 2D Area represents.
   *  - oklch-cl: Y = OKLCH lightness, X = chroma normalized to gamut. Perceptually uniform.
   *    Top row is white-ish (max chroma at L=1 is 0). Most-saturated colors live at mid-Y.
   *  - hsv-sv: Y = HSV-style "value", X = saturation. OKHSV-like — anchored to the gamut
   *    cusp so top-right corner is fully saturated (Photoshop/Framer feel).
   *  - oklch-hc: X = hue, Y = chroma normalized (lightness picked separately).
   */
  mode?: AreaMode;
  /**
   * Ignored when `gamut` is "srgb" / "p3" / "rec2020" — chroma fills the
   * square up to the gamut surface. Only meaningful when `gamut === "none"`,
   * where it bounds the absolute-chroma X axis.
   */
  chromaMax?: number;
  /**
   * Render gamut. The square is filled with in-gamut colors up to this
   * gamut's surface, and warning lines mark narrower-gamut cutoffs inside.
   * Defaults to the gamut implied by the active output format
   * (hex/rgb/hsl/hsb → srgb, p3 → p3, oklch/oklab → rec2020).
   * Pass "none" to disable warping and paint the raw OKLCH plane up to
   * `chromaMax` (no fill warp, no lines).
   */
  gamut?: AreaGamut;
  /**
   * Show the gamut-cutoff warning lines drawn inside the active render gamut.
   * Defaults to true. Setting to false hides them regardless of `gamut` —
   * useful for a quieter visual when the badge already conveys gamut status.
   * No effect when `gamut` is "srgb" or "none" (nothing to draw anyway).
   */
  showWarningLines?: boolean;
  /** Render resolution of the gradient canvas. Higher = sharper, slower. Default 160. */
  resolution?: number;
  /**
   * Soft-proof out-of-display colors instead of per-channel clipping them.
   * When the active render gamut exceeds the display's (e.g. `gamut="rec2020"`
   * on a P3 monitor), pixels past the display gamut are chroma-reduced in
   * OKLCH to the display surface — preserving hue and lightness, flattening
   * chroma. The default (false) preserves the legacy per-channel clip, which
   * is cheaper but introduces hue shifts and posterization in unrenderable
   * regions. Off by default to keep first-paint cost identical to before.
   */
  softProof?: boolean;
}

/** Which gamuts get a warning line drawn inside the active render gamut. */
function warningGamuts(active: AreaGamut): Gamut[] {
  switch (active) {
    case "srgb":
    case "none":
      return [];
    case "p3":
      return ["srgb"];
    case "rec2020":
      return ["srgb", "p3"];
  }
}

/**
 * Read display-P3 support fresh on the client and re-react if the user moves
 * the window to a different display. Module-load evaluation locks the value
 * to `false` in Next.js's SSR module cache (window is undefined there), and
 * `useState(() => ...)` would diverge between server and client renders.
 * `useSyncExternalStore` returns `false` on the server (matching SSR) and the
 * real value on the client during reconciliation — no hydration mismatch.
 */
const subscribeP3 =
  typeof window !== "undefined" && typeof window.matchMedia === "function"
    ? (onChange: () => void) => {
        const mq = window.matchMedia("(color-gamut: p3)");
        mq.addEventListener("change", onChange);
        return () => mq.removeEventListener("change", onChange);
      }
    : () => () => {};
const getP3Client = () =>
  typeof window !== "undefined" &&
  typeof window.matchMedia === "function" &&
  window.matchMedia("(color-gamut: p3)").matches;
const getP3Server = () => false;

export const Area = React.forwardRef<HTMLDivElement, AreaProps>(function Area(
  {
    mode = "oklch-cl",
    chromaMax = 0.4,
    gamut: gamutProp,
    showWarningLines = true,
    resolution = 160,
    softProof = false,
    className,
    ...rest
  },
  ref,
) {
  const { color, setColor, format } = useColorPickerContext();
  const supportsP3 = React.useSyncExternalStore(
    subscribeP3,
    getP3Client,
    getP3Server,
  );
  const gamut: AreaGamut = gamutProp ?? libGamutFromFormat(format);
  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
  const containerRef = React.useRef<HTMLDivElement | null>(null);
  const [paths, setPaths] = React.useState<string[][]>([]);
  // Bead position override. The (X, Y) → OKLCH mapping is lossy at the
  // gamut poles: at l=0 or l=1 every X collapses to chroma 0, so deriving
  // the bead back from the resulting color always recovers X=0 — making
  // the bead jump to the left edge when the pointer drags past the top or
  // bottom. We keep the user's last picked (xn, yn) and render the bead
  // there. Cleared whenever color changes from a source other than our
  // own moveTo (e.g. swatch click, controlled value, input typing).
  const [pickPos, setPickPos] = React.useState<[number, number] | null>(null);
  const selfSetRef = React.useRef(false);

  React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);

  // Adjusting state during rendering — React-blessed alternative to
  // syncing color → pickPos in a useEffect. We compare channel values
  // (not the OKLCH object identity, which a controlled ColorPicker may
  // re-create each render even when values are unchanged) against the
  // previous render's snapshot. When the user-driven path moved the
  // bead, `selfSetRef` short-circuits the clear so the override sticks.
  const [prevColor, setPrevColor] = React.useState(color);
  if (
    prevColor.l !== color.l ||
    prevColor.c !== color.c ||
    prevColor.h !== color.h ||
    prevColor.alpha !== color.alpha
  ) {
    setPrevColor(color);
    if (!selfSetRef.current && pickPos !== null) setPickPos(null);
  }
  // Disarm after every commit rather than on the echo: a pick that leaves
  // the color unchanged (e.g. any X at the white pole) never produces an
  // echo, and a still-armed flag would swallow the next *external* change.
  // Our own pick's color update lands in the same render as `setPickPos`.
  React.useEffect(() => {
    selfSetRef.current = false;
  });

  // The gradient and warning lines depend only on the axis the mode keeps
  // *fixed* (hue for oklch-cl/hsv-sv, lightness for oklch-hc). Depending on
  // every channel of `color` would trigger a 25 600-pixel canvas repaint plus
  // a 128² marching-squares pass on every pointer tick — enough to stall the
  // bead. Narrowing the dep to the locked axis keeps drags free.
  const fixedAxisValue = mode === "oklch-hc" ? color.l : color.h;
  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const w = resolution;
    const h = resolution;
    canvas.width = w;
    canvas.height = h;
    const ctx2dOpts: CanvasRenderingContext2DSettings = supportsP3
      ? { colorSpace: "display-p3" }
      : {};
    const ctx = canvas.getContext("2d", ctx2dOpts);
    if (!ctx) return;
    const img = ctx.createImageData(w, h);
    paintGradient(img, w, h, mode, color, chromaMax, gamut, supportsP3, softProof);
    ctx.putImageData(img, 0, 0);
    if (gamut === "none" || !showWarningLines) {
      setPaths([]);
    } else {
      setPaths(
        warningGamuts(gamut).map((g) =>
          computeGamutPaths(mode, color, chromaMax, g, gamut),
        ),
      );
    }
    // `color` is intentionally omitted — gradient + warning lines depend only
    // on the locked axis (`fixedAxisValue`), not on every channel of `color`.
    // Repainting on every pointer tick would stall the bead.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [mode, fixedAxisValue, chromaMax, gamut, showWarningLines, resolution, softProof, supportsP3]);

  const [derivedPx, derivedPy] = positionFor(mode, color, chromaMax, gamut);
  const [px, py] = pickPos ?? [derivedPx, derivedPy];

  // role="application" carries no value semantics, so keyboard adjustments
  // would otherwise be silent to screen readers. Announce the value text in a
  // polite live region, debounced so held-down arrows don't flood the queue.
  const [liveText, announce] = useLiveAnnounce();

  const moveTo = React.useCallback(
    (x: number, y: number) => {
      const xn = clamp01(x);
      const yn = clamp01(y);
      const next = sampleAt(mode, color, chromaMax, gamut, xn, yn);
      setPickPos([xn, yn]);
      selfSetRef.current = true;
      // With warping every (X, Y) is in-gamut by construction, so toGamut is
      // defensive only — guards against drift at numerical boundaries. When
      // gamut === "none" the user has explicitly opted out of warping and we
      // skip clamping entirely (raw OKLCH plane).
      let committed: OklchColor;
      if (gamut !== "none") {
        const targetHue = next.h;
        const clamped = toGamut(next, gamut as Gamut);
        committed = { ...clamped, h: targetHue };
      } else {
        committed = next;
      }
      setColor(committed);
      announce(ariaValueTextFor(mode, committed, chromaMax, gamut));
    },
    [mode, chromaMax, color, setColor, gamut, announce],
  );

  const handlePointer = React.useCallback(
    (clientX: number, clientY: number) => {
      const el = containerRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      // A collapsed / animating-out container has no pickable surface.
      if (rect.width <= 0 || rect.height <= 0) return;
      moveTo((clientX - rect.left) / rect.width, (clientY - rect.top) / rect.height);
    },
    [moveTo],
  );

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
    handlePointer(e.clientX, e.clientY);
  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (e.buttons !== 1) return;
    handlePointer(e.clientX, e.clientY);
  };
  const releaseCapture = (e: React.PointerEvent<HTMLDivElement>) => {
    const el = e.currentTarget as HTMLDivElement;
    if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const big = e.shiftKey ? 10 : 1;
    const stepX = big / 100; // % of axis
    const stepY = big / 100;
    let nx = px;
    let ny = py;
    switch (e.key) {
      case "ArrowLeft":
        nx -= stepX;
        break;
      case "ArrowRight":
        nx += stepX;
        break;
      case "ArrowUp":
        ny -= stepY;
        break;
      case "ArrowDown":
        ny += stepY;
        break;
      case "Home":
        nx = 0;
        break;
      case "End":
        nx = 1;
        break;
      case "PageUp":
        ny -= 0.1;
        break;
      case "PageDown":
        ny += 0.1;
        break;
      default:
        return;
    }
    e.preventDefault();
    moveTo(nx, ny);
  };

  const valueText = ariaValueTextFor(mode, color, chromaMax, gamut);

  return (
    <div
      ref={containerRef}
      data-slot="color-picker-area"
      role="application"
      aria-label={`Color area: ${valueText}`}
      aria-roledescription="2D color area, use arrow keys to adjust"
      tabIndex={0}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={releaseCapture}
      onPointerCancel={releaseCapture}
      onKeyDown={onKeyDown}
      className={cn(
        "relative h-45 w-full cursor-crosshair overflow-hidden rounded-md border border-border outline-none touch-none select-none",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
        className,
      )}
      {...rest}
    >
      <canvas
        ref={canvasRef}
        className="block h-full w-full"
        aria-hidden="true"
      />
      {paths.length > 0 && (
        <svg
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 h-full w-full"
          viewBox="0 0 1 1"
          preserveAspectRatio="none"
        >
          {paths.map((groupPaths, gi) =>
            groupPaths.map((d, i) => (
              <g key={`${gi}-${i}`}>
                <path
                  d={d}
                  fill="none"
                  stroke="rgba(0,0,0,0.55)"
                  strokeWidth={3}
                  strokeLinejoin="round"
                  strokeLinecap="round"
                  vectorEffect="non-scaling-stroke"
                />
                <path
                  d={d}
                  fill="none"
                  stroke="rgba(255,255,255,0.6)"
                  strokeWidth={1}
                  strokeLinejoin="round"
                  strokeLinecap="round"
                  vectorEffect="non-scaling-stroke"
                />
              </g>
            )),
          )}
        </svg>
      )}
      <span aria-live="polite" className="sr-only">
        {liveText}
      </span>
      <div
        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)]"
        style={{
          left: `${px * 100}%`,
          top: `${py * 100}%`,
          background: formatColor({ ...color, alpha: 1 }, "oklch"),
        }}
      />
    </div>
  );
});

function clamp01(x: number) {
  return x < 0 ? 0 : x > 1 ? 1 : x;
}

function positionFor(
  mode: AreaMode,
  c: OklchColor,
  chromaMax: number,
  gamut: AreaGamut,
): [number, number] {
  if (gamut === "none") {
    switch (mode) {
      case "oklch-cl":
        return [clamp01(c.c / chromaMax), clamp01(1 - c.l)];
      case "hsv-sv": {
        const v = c.l;
        const s = v > 0 ? c.c / Math.max(v * chromaMax, 1e-6) : 0;
        return [clamp01(s), clamp01(1 - v)];
      }
      case "oklch-hc":
        return [
          clamp01((((c.h % 360) + 360) % 360) / 360),
          clamp01(1 - c.c / chromaMax),
        ];
    }
  }
  switch (mode) {
    case "oklch-cl": {
      const maxC = findMaxChroma(c.l, c.h, gamut as Gamut);
      const safeMaxC = maxC > 1e-6 ? maxC : 1e-6;
      return [clamp01(c.c / safeMaxC), clamp01(1 - c.l)];
    }
    case "hsv-sv": {
      const cusp = findCusp(c.h, gamut as Gamut);
      // L = V·(1 − S·(1 − Lc)), C = V·S·Cc → solve for V, S.
      const V = clamp01(c.l + (c.c * (1 - cusp.l)) / Math.max(cusp.c, 1e-6));
      const S = V > 1e-6 ? clamp01(c.c / (V * Math.max(cusp.c, 1e-6))) : 0;
      return [S, clamp01(1 - V)];
    }
    case "oklch-hc": {
      const maxC = findMaxChroma(c.l, c.h, gamut as Gamut);
      const safeMaxC = maxC > 1e-6 ? maxC : 1e-6;
      return [
        clamp01((((c.h % 360) + 360) % 360) / 360),
        clamp01(1 - c.c / safeMaxC),
      ];
    }
  }
}

/**
 * Build the OKLCH color for a normalized (x, y) inside the area, given the
 * locked axis on `base`. Mirror of `positionFor`.
 */
function sampleAt(
  mode: AreaMode,
  base: OklchColor,
  chromaMax: number,
  gamut: AreaGamut,
  xn: number,
  yn: number,
): OklchColor {
  if (gamut === "none") {
    switch (mode) {
      case "oklch-cl":
        return { ...base, c: xn * chromaMax, l: 1 - yn };
      case "hsv-sv": {
        const v = 1 - yn;
        return { ...base, l: v, c: xn * v * chromaMax };
      }
      case "oklch-hc":
        return { ...base, h: xn * 360, c: (1 - yn) * chromaMax };
    }
  }
  switch (mode) {
    case "oklch-cl": {
      const l = 1 - yn;
      const maxC = findMaxChroma(l, base.h, gamut as Gamut);
      return { ...base, l, c: xn * maxC };
    }
    case "hsv-sv": {
      const cusp = findCusp(base.h, gamut as Gamut);
      const V = 1 - yn;
      const S = xn;
      const l = V * (1 - S * (1 - cusp.l));
      const c = V * S * cusp.c;
      return { ...base, l, c };
    }
    case "oklch-hc": {
      const h = xn * 360;
      const maxC = findMaxChroma(base.l, h, gamut as Gamut);
      return { ...base, h, c: (1 - yn) * maxC };
    }
  }
}

function ariaValueTextFor(
  mode: AreaMode,
  c: OklchColor,
  chromaMax: number,
  gamut: AreaGamut,
): string {
  switch (mode) {
    case "oklch-cl": {
      const maxC =
        gamut === "none"
          ? chromaMax
          : findMaxChroma(c.l, c.h, gamut as Gamut) || chromaMax;
      return `Lightness ${(c.l * 100).toFixed(0)} percent, chroma ${c.c.toFixed(2)} of ${maxC.toFixed(2)}, hue ${c.h.toFixed(0)} degrees`;
    }
    case "hsv-sv": {
      if (gamut === "none") {
        const v = c.l;
        const s = (c.c / Math.max(v * chromaMax, 1e-6)) * 100 || 0;
        return `Saturation ${s.toFixed(0)} percent, value ${(v * 100).toFixed(0)} percent, hue ${c.h.toFixed(0)} degrees`;
      }
      const cusp = findCusp(c.h, gamut as Gamut);
      const V = clamp01(c.l + (c.c * (1 - cusp.l)) / Math.max(cusp.c, 1e-6));
      const S = V > 1e-6 ? clamp01(c.c / (V * Math.max(cusp.c, 1e-6))) : 0;
      return `Saturation ${(S * 100).toFixed(0)} percent, value ${(V * 100).toFixed(0)} percent, hue ${c.h.toFixed(0)} degrees`;
    }
    case "oklch-hc": {
      const maxC =
        gamut === "none"
          ? chromaMax
          : findMaxChroma(c.l, c.h, gamut as Gamut) || chromaMax;
      return `Hue ${c.h.toFixed(0)} degrees, chroma ${c.c.toFixed(2)} of ${maxC.toFixed(2)}, lightness ${(c.l * 100).toFixed(0)} percent`;
    }
  }
}

/* ----------------------- gradient painting ----------------------- */
/* Per-paint LUT of max chroma for the moving axis. Each row (or column for
 * oklch-hc) gets one bisection so the per-pixel cost stays a single multiply.
 * When gamut === "none" we skip the LUT and paint absolute chroma up to
 * chromaMax (the legacy unwrapped plane). */

function paintGradient(
  img: ImageData,
  w: number,
  h: number,
  mode: AreaMode,
  base: OklchColor,
  chromaMax: number,
  gamut: AreaGamut,
  canvasIsP3: boolean,
  softProof: boolean,
) {
  const data = img.data;
  const ctx = buildWarpContext(mode, base, gamut, w, h);
  // Soft-proof: build a parallel max-chroma context keyed to the *display*
  // gamut. Per pixel we'll clamp chroma down to this surface so out-of-display
  // OKLCH samples render as their hue/lightness-faithful gamut-mapped twin
  // instead of a per-channel-clipped fake. Skipping the displayCtx entirely
  // when render gamut already fits inside the display avoids a useless second
  // LUT. "none" gamut opts out of warping in general, so soft-proof is a no-op.
  const displayCap: AreaGamut =
    gamut === "none"
      ? "none"
      : canvasIsP3
        ? "p3"
        : "srgb";
  const needsSoftProof =
    softProof && gamut !== "none" && isWiderThan(gamut, displayCap);
  const displayCtx: WarpContext | null = needsSoftProof
    ? buildWarpContext(mode, base, displayCap, w, h)
    : null;

  for (let yPx = 0; yPx < h; yPx++) {
    const yn = yPx / (h - 1);
    for (let xPx = 0; xPx < w; xPx++) {
      const xn = xPx / (w - 1);

      const [l, c, hue] = warpedSample(ctx, mode, base, chromaMax, xn, yn, xPx, yPx);
      const cClamped = displayCtx
        ? Math.min(c, displayMaxChroma(displayCtx, mode, l, xPx, yPx))
        : c;

      const lin = oklchToLinearSrgb(l, cClamped, hue);
      const targetLin = canvasIsP3 ? linSrgbToLinP3(lin) : lin;
      const idx = (yPx * w + xPx) * 4;
      data[idx] = clampByte(srgbEncode(targetLin.r) * 255);
      data[idx + 1] = clampByte(srgbEncode(targetLin.g) * 255);
      data[idx + 2] = clampByte(srgbEncode(targetLin.b) * 255);
      data[idx + 3] = 255;
    }
  }
}

function isWiderThan(a: AreaGamut, b: AreaGamut): boolean {
  const rank: Record<AreaGamut, number> = { none: 99, rec2020: 3, p3: 2, srgb: 1 };
  return rank[a] > rank[b];
}

/**
 * Read the display-gamut max chroma at this pixel using the soft-proof warp
 * context. For oklch-cl/oklch-hc the precomputed 1D LUT is row-/column-keyed.
 * For hsv-sv we evaluate the OKHSV-ish triangle around the locked-hue cusp:
 * chroma rises linearly from 0 at l=0 to cusp.c at l=cusp.l, then falls back
 * to 0 at l=1. That matches how `warpedSample` already builds hsv-sv samples,
 * so the clamp is consistent with the warp.
 */
function displayMaxChroma(
  ctx: WarpContext,
  mode: AreaMode,
  l: number,
  xPx: number,
  yPx: number,
): number {
  switch (ctx.kind) {
    case "none":
      return Number.POSITIVE_INFINITY;
    case "lut-y":
      return ctx.lut[yPx];
    case "lut-x":
      return ctx.lut[xPx];
    case "cusp": {
      if (mode !== "hsv-sv") return Number.POSITIVE_INFINITY;
      const { l: cl, c: cc } = ctx.cusp;
      if (l <= cl) return cc * (l / Math.max(cl, 1e-6));
      return cc * ((1 - l) / Math.max(1 - cl, 1e-6));
    }
  }
}

/**
 * Per-paint warp resources. For oklch-cl and oklch-hc we precompute a 1D
 * max-chroma LUT keyed by the moving axis. For hsv-sv we precompute the
 * (locked-hue) cusp once. For "none" we hold no resources — the legacy raw
 * mapping uses chromaMax directly.
 */
type WarpContext =
  | { kind: "none" }
  | { kind: "lut-y"; lut: Float32Array }
  | { kind: "lut-x"; lut: Float32Array }
  | { kind: "cusp"; cusp: { l: number; c: number } };

function buildWarpContext(
  mode: AreaMode,
  base: OklchColor,
  gamut: AreaGamut,
  w: number,
  h: number,
): WarpContext {
  if (gamut === "none") return { kind: "none" };
  switch (mode) {
    case "oklch-cl": {
      const lut = new Float32Array(h);
      for (let j = 0; j < h; j++) {
        const l = 1 - j / (h - 1);
        lut[j] = findMaxChroma(l, base.h, gamut as Gamut);
      }
      return { kind: "lut-y", lut };
    }
    case "oklch-hc": {
      const lut = new Float32Array(w);
      for (let i = 0; i < w; i++) {
        const hue = (i / (w - 1)) * 360;
        lut[i] = findMaxChroma(base.l, hue, gamut as Gamut);
      }
      return { kind: "lut-x", lut };
    }
    case "hsv-sv":
      return { kind: "cusp", cusp: findCusp(base.h, gamut as Gamut) };
  }
}

/**
 * Read (l, c, hue) for a normalized (xn, yn) using the precomputed warp
 * context. `xPx`/`yPx` index into the LUT for oklch-cl/oklch-hc; hsv-sv and
 * "none" don't use them.
 */
function warpedSample(
  ctx: WarpContext,
  mode: AreaMode,
  base: OklchColor,
  chromaMax: number,
  xn: number,
  yn: number,
  xPx: number,
  yPx: number,
): [number, number, number] {
  if (ctx.kind === "none") {
    const ok = sampleRaw(mode, base, chromaMax, xn, yn);
    return [ok.l, ok.c, ok.h];
  }
  switch (mode) {
    case "oklch-cl": {
      const l = 1 - yn;
      const maxC = (ctx as { kind: "lut-y"; lut: Float32Array }).lut[yPx];
      return [l, xn * maxC, base.h];
    }
    case "hsv-sv": {
      const cusp = (ctx as { kind: "cusp"; cusp: { l: number; c: number } }).cusp;
      const V = 1 - yn;
      const S = xn;
      const l = V * (1 - S * (1 - cusp.l));
      const c = V * S * cusp.c;
      return [l, c, base.h];
    }
    case "oklch-hc": {
      const hue = xn * 360;
      const maxC = (ctx as { kind: "lut-x"; lut: Float32Array }).lut[xPx];
      return [base.l, (1 - yn) * maxC, hue];
    }
  }
}

function sampleRaw(
  mode: AreaMode,
  base: OklchColor,
  chromaMax: number,
  xn: number,
  yn: number,
): { l: number; c: number; h: number } {
  switch (mode) {
    case "oklch-cl":
      return { l: 1 - yn, c: xn * chromaMax, h: base.h };
    case "hsv-sv": {
      const v = 1 - yn;
      return { l: v, c: xn * v * chromaMax, h: base.h };
    }
    case "oklch-hc":
      return { l: base.l, c: (1 - yn) * chromaMax, h: xn * 360 };
  }
}

/**
 * Compute a warning gamut's boundary inside the *active render gamut's* warped
 * coordinate space, as SVG path strings in normalized 0..1 coords. Marching
 * squares on a 128² grid where each cell samples gamutSignedDistance against
 * the warning gamut, with sample positions warped through the active gamut's
 * max-chroma so the line lands on the correct (X, Y) inside the filled square.
 */
function computeGamutPaths(
  mode: AreaMode,
  base: OklchColor,
  chromaMax: number,
  warningGamut: Gamut,
  activeGamut: AreaGamut,
): string[] {
  const N = 128;
  const stride = N + 1;
  const sd = new Float32Array(stride * stride);
  // Precompute hsv-sv's cusp once; without this the 16 384 grid samples would
  // each rebuild it (52 findMaxChroma calls each → ~850 K total).
  const cusp =
    mode === "hsv-sv" && activeGamut !== "none"
      ? findCusp(base.h, activeGamut as Gamut)
      : null;
  for (let j = 0; j <= N; j++) {
    const yn = j / N;
    for (let i = 0; i <= N; i++) {
      const xn = i / N;
      const ok =
        activeGamut === "none"
          ? sampleRaw(mode, base, chromaMax, xn, yn)
          : sampleWarpedForLine(mode, base, activeGamut as Gamut, xn, yn, cusp);
      sd[j * stride + i] = gamutSignedDistance(
        { l: ok.l, c: ok.c, h: ok.h, alpha: 1 },
        warningGamut,
      );
    }
  }

  const interp = (
    ax: number, ay: number, av: number,
    bx: number, by: number, bv: number,
  ): [number, number] => {
    const t = av / (av - bv);
    return [ax + (bx - ax) * t, ay + (by - ay) * t];
  };

  const segs: number[] = []; // flat: [ax, ay, bx, by, ax, ay, bx, by, ...]
  const pushSeg = (a: [number, number], b: [number, number]) => {
    if (a[0] === b[0] && a[1] === b[1]) return;
    segs.push(a[0], a[1], b[0], b[1]);
  };
  for (let j = 0; j < N; j++) {
    for (let i = 0; i < N; i++) {
      const aTL = sd[j * stride + i];
      const aTR = sd[j * stride + i + 1];
      const aBL = sd[(j + 1) * stride + i];
      const aBR = sd[(j + 1) * stride + i + 1];
      const code =
        (aTL > 0 ? 8 : 0) |
        (aTR > 0 ? 4 : 0) |
        (aBR > 0 ? 2 : 0) |
        (aBL > 0 ? 1 : 0);
      if (code === 0 || code === 15) continue;
      const x0 = i / N;
      const y0 = j / N;
      const x1 = (i + 1) / N;
      const y1 = (j + 1) / N;
      const top = () => interp(x0, y0, aTL, x1, y0, aTR);
      const right = () => interp(x1, y0, aTR, x1, y1, aBR);
      const bottom = () => interp(x0, y1, aBL, x1, y1, aBR);
      const left = () => interp(x0, y0, aTL, x0, y1, aBL);
      switch (code) {
        case 1: case 14: pushSeg(left(), bottom()); break;
        case 2: case 13: pushSeg(bottom(), right()); break;
        case 3: case 12: pushSeg(left(), right()); break;
        case 4: case 11: pushSeg(top(), right()); break;
        case 6: case 9:  pushSeg(top(), bottom()); break;
        case 7: case 8:  pushSeg(left(), top()); break;
        case 5:  pushSeg(left(), top()); pushSeg(bottom(), right()); break;
        case 10: pushSeg(left(), bottom()); pushSeg(top(), right()); break;
      }
    }
  }

  const segCount = segs.length / 4;
  if (segCount === 0) return [];

  // Build endpoint map keyed by quantized coords. Endpoints from adjacent cells
  // share the same `interp` inputs along their shared edge, so they collide
  // exactly — quantization only protects against float ULP drift.
  const Q = 1_000_000;
  const key = (x: number, y: number) => `${(x * Q) | 0},${(y * Q) | 0}`;
  type Endpoint = { seg: number; end: 0 | 1 };
  const endpointMap = new Map<string, Endpoint[]>();
  const addEp = (k: string, e: Endpoint) => {
    const arr = endpointMap.get(k);
    if (arr) arr.push(e);
    else endpointMap.set(k, [e]);
  };
  for (let s = 0; s < segCount; s++) {
    const o = s * 4;
    addEp(key(segs[o], segs[o + 1]), { seg: s, end: 0 });
    addEp(key(segs[o + 2], segs[o + 3]), { seg: s, end: 1 });
  }

  const used = new Uint8Array(segCount);
  const popEndpointAt = (k: string): Endpoint | null => {
    const arr = endpointMap.get(k);
    if (!arr) return null;
    while (arr.length > 0) {
      const e = arr.pop()!;
      if (!used[e.seg]) return e;
    }
    return null;
  };

  const polylines: number[][] = [];
  for (let start = 0; start < segCount; start++) {
    if (used[start]) continue;
    used[start] = 1;
    const o = start * 4;
    const pts: number[] = [segs[o], segs[o + 1], segs[o + 2], segs[o + 3]];

    // Extend forward off the tail.
    while (true) {
      const tx = pts[pts.length - 2];
      const ty = pts[pts.length - 1];
      const e = popEndpointAt(key(tx, ty));
      if (!e) break;
      used[e.seg] = 1;
      const no = e.seg * 4;
      if (e.end === 0) pts.push(segs[no + 2], segs[no + 3]);
      else pts.push(segs[no], segs[no + 1]);
    }
    // Extend backward off the head.
    while (true) {
      const hx = pts[0];
      const hy = pts[1];
      const e = popEndpointAt(key(hx, hy));
      if (!e) break;
      used[e.seg] = 1;
      const no = e.seg * 4;
      if (e.end === 0) pts.unshift(segs[no + 2], segs[no + 3]);
      else pts.unshift(segs[no], segs[no + 1]);
    }

    polylines.push(pts);
  }

  const fmt = (v: number) => v.toFixed(5);
  return polylines.map((pts) => {
    const last = pts.length;
    const closed =
      last >= 4 && pts[0] === pts[last - 2] && pts[1] === pts[last - 1];
    let d = `M${fmt(pts[0])},${fmt(pts[1])}`;
    for (let i = 2; i < last; i += 2) {
      d += `L${fmt(pts[i])},${fmt(pts[i + 1])}`;
    }
    if (closed) d += "Z";
    return d;
  });
}

/**
 * Sample helper for the warning-line marching squares. Mirrors `sampleAt`
 * but takes raw inputs (no `gamut === "none"` branch — callers handle it).
 * `cusp` is required for hsv-sv mode; precomputed by the caller for speed.
 */
function sampleWarpedForLine(
  mode: AreaMode,
  base: OklchColor,
  activeGamut: Gamut,
  xn: number,
  yn: number,
  cusp: { l: number; c: number } | null,
): { l: number; c: number; h: number } {
  switch (mode) {
    case "oklch-cl": {
      const l = 1 - yn;
      const maxC = findMaxChroma(l, base.h, activeGamut);
      return { l, c: xn * maxC, h: base.h };
    }
    case "hsv-sv": {
      const k = cusp ?? findCusp(base.h, activeGamut);
      const V = 1 - yn;
      const S = xn;
      const l = V * (1 - S * (1 - k.l));
      const c = V * S * k.c;
      return { l, c, h: base.h };
    }
    case "oklch-hc": {
      const h = xn * 360;
      const maxC = findMaxChroma(base.l, h, activeGamut);
      return { l: base.l, c: (1 - yn) * maxC, h };
    }
  }
}

function srgbEncode(v: number) {
  const x = v < 0 ? 0 : v > 1 ? 1 : v;
  return x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
}

function clampByte(x: number) {
  return x < 0 ? 0 : x > 255 ? 255 : Math.round(x);
}
registry/new-york/ui/fill-picker/parts/chroma.tsx
"use client";

import * as React from "react";
import { useColorPickerContext } from "../context";
import { formatColor } from "../lib/color";
import { cn } from "@/lib/utils";

/**
 * OKLCH chroma is unbounded in principle but practically maxes out
 * around ~0.4 for the wide hues; gamut-clamping handles anything
 * higher. Matches the slider/typed range in `lib/channels.ts`.
 */
const CHROMA_MAX = 0.4;

export interface ChromaProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "onKeyDown"> {
  orientation?: "horizontal" | "vertical";
}

export const Chroma = React.forwardRef<HTMLDivElement, ChromaProps>(
  function Chroma({ orientation = "horizontal", className, ...rest }, ref) {
    const { color, setComponent } = useColorPickerContext();
    const trackRef = React.useRef<HTMLDivElement | null>(null);
    React.useImperativeHandle(ref, () => trackRef.current as HTMLDivElement);

    const moveTo = (clientCoord: number) => {
      const el = trackRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const ratio =
        orientation === "horizontal"
          ? (clientCoord - rect.left) / rect.width
          : // Vertical is bottom-anchored (min at the bottom), matching the
            // Base UI variant and ArrowUp = increase.
            1 - (clientCoord - rect.top) / rect.height;
      const clamped = Math.max(0, Math.min(1, ratio));
      setComponent("c", clamped * CHROMA_MAX);
    };

    const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
      (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
      moveTo(orientation === "horizontal" ? e.clientX : e.clientY);
    };
    const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
      if (e.buttons !== 1) return;
      moveTo(orientation === "horizontal" ? e.clientX : e.clientY);
    };
    const releaseCapture = (e: React.PointerEvent<HTMLDivElement>) => {
      const el = e.currentTarget as HTMLDivElement;
      if (el.hasPointerCapture(e.pointerId))
        el.releasePointerCapture(e.pointerId);
    };

    const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
      const big = e.shiftKey ? 0.05 : 0.005;
      let next = color.c;
      switch (e.key) {
        case "ArrowLeft":
        case "ArrowDown":
          next -= big;
          break;
        case "ArrowRight":
        case "ArrowUp":
          next += big;
          break;
        case "Home":
          next = 0;
          break;
        case "End":
          next = CHROMA_MAX;
          break;
        case "PageUp":
          next += 0.05;
          break;
        case "PageDown":
          next -= 0.05;
          break;
        default:
          return;
      }
      e.preventDefault();
      setComponent("c", Math.max(0, Math.min(CHROMA_MAX, next)));
    };

    const isVertical = orientation === "vertical";
    // Build the gradient at the current hue & lightness so the ramp shows
    // how chroma changes _their_ color, not a generic gray→vivid ramp.
    const stops = React.useMemo(() => {
      const samples = 8;
      const arr: string[] = [];
      for (let i = 0; i <= samples; i++) {
        const c = (i / samples) * CHROMA_MAX;
        arr.push(formatColor({ l: color.l, c, h: color.h, alpha: 1 }, "oklch"));
      }
      return arr.join(", ");
    }, [color.h, color.l]);

    const ratio = Math.max(0, Math.min(1, color.c / CHROMA_MAX));

    return (
      <div
        ref={trackRef}
        data-slot="color-picker-chroma"
        role="slider"
        aria-label="Chroma"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(ratio * 100)}
        aria-valuetext={`${color.c.toFixed(3)}`}
        aria-orientation={orientation}
        tabIndex={0}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={releaseCapture}
        onPointerCancel={releaseCapture}
        onKeyDown={onKeyDown}
        className={cn(
          "relative cursor-pointer rounded-full outline-none touch-none",
          isVertical ? "h-32 w-3" : "h-3 w-full",
          "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
          className,
        )}
        style={{
          background: isVertical
            ? `linear-gradient(to top, ${stops})`
            : `linear-gradient(to right, ${stops})`,
        }}
        {...rest}
      >
        <div
          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)]"
          style={
            isVertical
              ? {
                  left: "50%",
                  top: `calc((1 - ${ratio}) * (100% - 16px) + 8px)`,
                  background: formatColor({ ...color, alpha: 1 }, "oklch"),
                }
              : {
                  left: `calc(${ratio} * (100% - 16px) + 8px)`,
                  top: "50%",
                  background: formatColor({ ...color, alpha: 1 }, "oklch"),
                }
          }
        />
      </div>
    );
  },
);
registry/new-york/ui/fill-picker/parts/css-input.tsx
"use client";

import * as React from "react";
import { useColorPickerContext } from "../context";
import { cn } from "@/lib/utils";
import { FieldInput, FieldShell } from "./field";

export interface CssInputProps
  extends Omit<
    React.InputHTMLAttributes<HTMLInputElement>,
    "value" | "onChange"
  > {}

export const CssInput = React.forwardRef<HTMLInputElement, CssInputProps>(
  function CssInput({ className, ...rest }, ref) {
    const { formatted, setFromString } = useColorPickerContext();
    // Sync draft when canonical value changes externally (slider drags etc.)
    // via in-render adjustment — no useEffect needed.
    const [draft, setDraft] = React.useState(formatted);
    const [prevFormatted, setPrevFormatted] = React.useState(formatted);
    const [error, setError] = React.useState(false);
    if (formatted !== prevFormatted) {
      setPrevFormatted(formatted);
      setDraft(formatted);
      setError(false);
    }

    const commit = (value: string) => {
      const ok = setFromString(value.trim());
      setError(!ok);
    };

    return (
      <FieldShell
        data-slot="color-picker-input"
        className={cn("w-full", className)}
      >
        <FieldInput
          ref={ref}
          value={draft}
          aria-invalid={error || undefined}
          aria-label="Color value"
          onChange={(e) => {
            setDraft(e.target.value);
            setError(false);
          }}
          onBlur={(e) => commit(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              e.preventDefault();
              commit(e.currentTarget.value);
            } else if (e.key === "Escape") {
              setDraft(formatted);
              setError(false);
            }
          }}
          className={cn(
            "flex-1 px-2 text-left",
            error && "text-destructive",
          )}
          {...rest}
        />
      </FieldShell>
    );
  },
);
registry/new-york/ui/fill-picker/parts/preview.tsx
"use client";

import * as React from "react";
import { useColorPickerContext } from "../context";
import { formatColor } from "../lib/color";
import { cn } from "@/lib/utils";
import { CHECKERBOARD_LG, SAMPLE_EDGE } from "../lib/constants";

export interface PreviewProps extends React.HTMLAttributes<HTMLDivElement> {}

export const Preview = React.forwardRef<HTMLDivElement, PreviewProps>(function Preview(
  { className, ...rest },
  ref,
) {
  const { color, background } = useColorPickerContext();
  const fg = formatColor(color, "p3");
  const bg = formatColor(background, "p3");
  return (
    <div
      ref={ref}
      data-slot="color-picker-preview"
      role="img"
      aria-label="Color preview over background"
      className={cn(
        "relative size-10 shrink-0 overflow-hidden rounded-md",
        SAMPLE_EDGE,
        className,
      )}
      style={{
        backgroundImage: CHECKERBOARD_LG,
        backgroundSize: "12px 12px",
      }}
      {...rest}
    >
      <div
        aria-hidden="true"
        className="absolute inset-0"
        style={{ background: bg }}
      />
      <div
        aria-hidden="true"
        className="absolute inset-0"
        style={{ background: fg }}
      />
    </div>
  );
});
registry/new-york/ui/fill-picker/parts/eye-dropper.tsx
"use client";

import * as React from "react";
import { Pipette } from "lucide-react";
import { useColorPickerContext } from "../context";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

interface EyeDropperLike {
  open: (opts?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>;
}

declare global {
  interface Window {
    EyeDropper?: { new (): EyeDropperLike };
  }
}

const subscribeNoop = () => () => {};
const getEyeDropperSupportClient = () =>
  typeof window !== "undefined" && typeof window.EyeDropper === "function";
const getEyeDropperSupportServer = () => false;

export interface EyeDropperProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

export const EyeDropper = React.forwardRef<HTMLButtonElement, EyeDropperProps>(function EyeDropper(
  { className, ...rest },
  ref,
) {
  const { setColor } = useColorPickerContext();
  // Client-only feature detection without a hydration mismatch and without the
  // two-paint flash that an `useEffect(setState)` pattern introduces. The
  // server snapshot returns `false` (matching SSR's empty render); the client
  // snapshot reads the real value during hydration's reconciliation pass.
  const supported = React.useSyncExternalStore(
    subscribeNoop,
    getEyeDropperSupportClient,
    getEyeDropperSupportServer,
  );

  const [active, setActive] = React.useState(false);

  if (!supported) return null;

  const onClick = async () => {
    setActive(true);
    try {
      const ed = new window.EyeDropper!();
      const result = await ed.open();
      if (result?.sRGBHex) setColor(result.sRGBHex);
    } catch {
      // user cancelled
    } finally {
      setActive(false);
    }
  };

  return (
    <Button
      ref={ref}
      data-slot="color-picker-eye-dropper"
      data-state={active ? "on" : "off"}
      type="button"
      variant="outline"
      size="icon-sm"
      aria-label="Pick color from screen"
      aria-pressed={active}
      onClick={onClick}
      className={cn(
        "cursor-pointer",
        active && "border-foreground/70 bg-foreground/10 text-foreground hover:bg-foreground/15",
        className,
      )}
      {...rest}
    >
      <Pipette className="size-4" />
    </Button>
  );
});
registry/new-york/ui/fill-picker/parts/gamut-badge-shared.ts
// Tooltip-agnostic internals of GamutBadge, shared by the Radix shell
// (parts/gamut-badge.tsx) and the Base UI shell
// (fill-picker-base/parts/gamut-badge.tsx).

import type * as React from "react";
import type { GamutInfo } from "../lib/types";

export interface GamutBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Show the "Gamut" prefix label. Default true. */
  showLabel?: boolean;
}

export const GAMUT_BADGE_CLASS =
  "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";

export function gamutLabel(
  gamut: Pick<GamutInfo, "inSrgb" | "inP3" | "inRec2020">,
): string {
  if (!gamut.inSrgb && gamut.inP3) return "P3";
  if (!gamut.inP3 && gamut.inRec2020) return "Rec.2020";
  if (!gamut.inRec2020) return "Out of gamut";
  return "sRGB";
}
registry/new-york/ui/fill-picker/parts/contrast-readout-shared.tsx
"use client";

// Tooltip-agnostic internals of ContrastReadout, shared by the Radix shell
// (parts/contrast-readout.tsx) and the Base UI shell
// (fill-picker-base/parts/contrast-readout.tsx). Radix and Base UI spell
// tooltip composition incompatibly (asChild vs render, delayDuration vs
// delay), so each variant owns only that wiring; everything else lives here.

import * as React from "react";
import { Check, X } from "lucide-react";
import { useColorPickerContext } from "../context";
import { formatColor } from "../lib/color";
import { cn } from "@/lib/utils";
import { CHECKERBOARD_SM } from "../lib/constants";

export type ContrastMetric = "wcag" | "apca";

export interface ContrastReadoutProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * Which contrast metrics are available. The first entry is shown by default;
   * if more than one is provided, the readout becomes a button and the user
   * clicks it to cycle to the next metric. Defaults to ["wcag"].
   */
  metrics?: ContrastMetric[];
  /** Override the initial metric. Must be present in `metrics`. */
  defaultMetric?: ContrastMetric;
  /** Show the metric label ("WCAG" / "APCA"). Default true. */
  showLabel?: boolean;
  /** Show the numeric value (ratio / Lc). Default true. */
  showValue?: boolean;
  /** Show the pass/fail level badges (AA, AAA, body, headline, fail). Default true. */
  showBadges?: boolean;
}

const DEFAULT_METRICS: ContrastMetric[] = ["wcag"];

export interface PassRow {
  ok: boolean;
  label: string;
  detail: string;
}

export const CONTRAST_READOUT_CLASS =
  "flex w-full items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1.5 text-xs";

export function useContrastReadout({
  metrics = DEFAULT_METRICS,
  defaultMetric,
  showLabel = true,
  showValue = true,
  showBadges = true,
}: Pick<
  ContrastReadoutProps,
  "metrics" | "defaultMetric" | "showLabel" | "showValue" | "showBadges"
>) {
  // Empty metrics would leave activeMetric undefined and silently render
  // the APCA branch — treat [] as "not provided".
  if (metrics.length === 0) metrics = DEFAULT_METRICS;

  const { contrast, color, background } = useColorPickerContext();
  const fgCss = formatColor(color, "p3");
  const bgCss = formatColor(background, "p3");
  const initial =
    defaultMetric && metrics.includes(defaultMetric) ? defaultMetric : metrics[0];
  const [active, setActive] = React.useState<ContrastMetric>(initial);

  // If the parent narrows `metrics` so the stored choice is no longer
  // offered, fall back to the first option. Derived, not synced with
  // setState-during-render (which is NOT visible to the rest of the same
  // render pass): the stale stored value simply stops being used, and the
  // user's choice resurrects if the parent restores that metric.
  const activeMetric = metrics.includes(active) ? active : metrics[0];

  const togglable = metrics.length > 1;

  // Spoken summary of a metric — the ratio and pass/fail must be part of the
  // accessible name, not just the visible text the aria-label would
  // otherwise override.
  const describeMetric = (metric: ContrastMetric) =>
    metric === "wcag"
      ? `WCAG ${contrast.wcag.toFixed(2)} to 1, AA ${
          contrast.wcagLevel.aaNormal ? "pass" : "fail"
        }, AAA ${contrast.wcagLevel.aaaNormal ? "pass" : "fail"}`
      : `APCA Lc ${contrast.apca.toFixed(1)}, ${apcaLevel(contrast.apca)}`;

  const summary = describeMetric(activeMetric);
  // Announced only when the user cycles metrics — announcing every color
  // change would flood the SR queue during drags.
  const [cycleAnnouncement, setCycleAnnouncement] = React.useState("");
  const cycle = () => {
    const i = metrics.indexOf(activeMetric);
    const next = metrics[(i + 1) % metrics.length];
    setActive(next);
    setCycleAnnouncement(describeMetric(next));
  };
  const nextMetric = metrics[(metrics.indexOf(activeMetric) + 1) % metrics.length];

  const body =
    activeMetric === "wcag" ? (
      <WcagBody
        wcag={contrast.wcag}
        aa={contrast.wcagLevel.aaNormal}
        aaa={contrast.wcagLevel.aaaNormal}
        showLabel={showLabel}
        showValue={showValue}
        showBadges={showBadges}
      />
    ) : (
      <ApcaBody
        lc={contrast.apca}
        showLabel={showLabel}
        showValue={showValue}
        showBadges={showBadges}
      />
    );

  const popover =
    activeMetric === "wcag"
      ? wcagPopover(
          contrast.wcag,
          contrast.wcagLevel.aaNormal,
          contrast.wcagLevel.aaaNormal,
        )
      : apcaPopover(contrast.apca);

  return {
    active: activeMetric,
    togglable,
    nextMetric,
    summary,
    cycle,
    cycleAnnouncement,
    body,
    popover,
    fgCss,
    bgCss,
  };
}

function apcaLevel(lc: number): "headline" | "body" | "fail" {
  const abs = Math.abs(lc);
  return abs >= 75 ? "headline" : abs >= 60 ? "body" : "fail";
}

export function WcagBody({
  wcag,
  aa,
  aaa,
  showLabel,
  showValue,
  showBadges,
}: {
  wcag: number;
  aa: boolean;
  aaa: boolean;
  showLabel: boolean;
  showValue: boolean;
  showBadges: boolean;
}) {
  return (
    <>
      {(showLabel || showValue) && (
        <div className="flex items-center gap-1.5">
          {showLabel && <span className="text-muted-foreground">WCAG</span>}
          {showValue && (
            <span className="font-mono font-medium">{wcag.toFixed(2)}:1</span>
          )}
        </div>
      )}
      {showBadges && (
        <div className="flex items-center gap-1">
          <Badge ok={aa}>AA</Badge>
          <Badge ok={aaa}>AAA</Badge>
        </div>
      )}
    </>
  );
}

export function ApcaBody({
  lc,
  showLabel,
  showValue,
  showBadges,
}: {
  lc: number;
  showLabel: boolean;
  showValue: boolean;
  showBadges: boolean;
}) {
  const level = apcaLevel(lc);
  return (
    <>
      {(showLabel || showValue) && (
        <div className="flex items-center gap-1.5">
          {showLabel && <span className="text-muted-foreground">APCA</span>}
          {showValue && (
            <span className="font-mono font-medium">Lc {lc.toFixed(1)}</span>
          )}
        </div>
      )}
      {showBadges && (
        <div className="flex items-center gap-1">
          <Badge ok={level !== "fail"}>
            {level === "headline" ? "headline" : level === "body" ? "body" : "fail"}
          </Badge>
        </div>
      )}
    </>
  );
}

function Badge({ ok, children }: { ok: boolean; children: React.ReactNode }) {
  return (
    <span
      aria-label={typeof children === "string" ? `${children} ${ok ? "passes" : "fails"}` : undefined}
      className={cn(
        "rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider",
        ok
          ? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400"
          : "bg-red-500/15 text-red-700 dark:text-red-400",
      )}
    >
      {children}
    </span>
  );
}

export function ContrastPopoverPanel({
  title,
  rows,
  fg,
  bg,
  footer,
}: {
  title: string;
  rows: PassRow[];
  fg: string;
  bg: string;
  footer?: string;
}) {
  return (
    <div className="flex flex-col gap-2 text-left">
      <div className="flex items-center justify-between gap-3">
        <div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
          {title}
        </div>
        <div
          aria-hidden
          className="flex shrink-0 overflow-hidden rounded border border-border"
          title={`fg ${fg} on bg ${bg}`}
        >
          <Chip color={fg} />
          <Chip color={bg} />
        </div>
      </div>
      <ul className="flex flex-col gap-1.5">
        {rows.map((r) => (
          <li key={r.label} className="flex items-start gap-2">
            <span
              aria-hidden
              className={cn(
                "mt-0.5 inline-flex size-3.5 shrink-0 items-center justify-center rounded-full",
                r.ok
                  ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
                  : "bg-red-500/20 text-red-600 dark:text-red-400",
              )}
            >
              {r.ok ? <Check className="size-2.5" /> : <X className="size-2.5" />}
            </span>
            <div className="flex flex-col gap-0.5">
              <span className="text-xs font-medium leading-tight">{r.label}</span>
              <span className="text-[11px] leading-snug text-muted-foreground">
                {r.detail}
              </span>
            </div>
          </li>
        ))}
      </ul>
      {footer && (
        <div className="border-t border-border pt-1.5 text-[11px] text-muted-foreground">
          {footer}
        </div>
      )}
    </div>
  );
}

function Chip({ color }: { color: string }) {
  return (
    <span
      className="block size-4"
      style={{ backgroundImage: CHECKERBOARD_SM, backgroundSize: "8px 8px" }}
    >
      <span className="block size-full" style={{ background: color }} />
    </span>
  );
}

export function wcagPopover(
  ratio: number,
  aa: boolean,
  aaa: boolean,
): { title: string; rows: PassRow[] } {
  return {
    title: `WCAG ${ratio.toFixed(2)}:1`,
    rows: [
      {
        ok: aa,
        label: aa ? "Passes AA" : "Fails AA",
        detail: "Body text needs ≥ 4.5:1",
      },
      {
        ok: aaa,
        label: aaa ? "Passes AAA" : "Fails AAA",
        detail: "Enhanced body text needs ≥ 7:1",
      },
    ],
  };
}

export function apcaPopover(lc: number): { title: string; rows: PassRow[] } {
  const abs = Math.abs(lc);
  const passesBody = abs >= 60;
  const passesHeadline = abs >= 75;
  return {
    title: `APCA Lc ${lc.toFixed(1)}`,
    rows: [
      {
        ok: passesBody,
        label: passesBody ? "Passes body text" : "Fails body text",
        detail: "Body text needs |Lc| ≥ 60",
      },
      {
        ok: passesHeadline,
        label: passesHeadline ? "Passes headlines" : "Fails headlines",
        detail: "Headline / large text needs |Lc| ≥ 75",
      },
    ],
  };
}
registry/new-york/ui/fill-picker/parts/use-live-announce.ts
"use client";

import * as React from "react";

/**
 * Debounced screen-reader announcements for 2D controls (`role="application"`
 * pads/handles) that have no native value semantics. Render `liveText` inside
 * a visually hidden `aria-live="polite"` element and call `announce` after
 * each keyboard-driven change; the debounce keeps held-down arrow keys from
 * flooding the SR queue.
 */
export function useLiveAnnounce(
  delayMs = 150,
): [string, (text: string) => void] {
  const [liveText, setLiveText] = React.useState("");
  const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const announce = React.useCallback(
    (text: string) => {
      if (timerRef.current) clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => setLiveText(text), delayMs);
    },
    [delayMs],
  );
  React.useEffect(
    () => () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    },
    [],
  );
  return [liveText, announce];
}

More from amplo

Browse all components in this library →