shadimage
amplo
uigradient-picker-enginesource: ok · refreshed

Amplo Gradient Picker Engine

Dialect-agnostic core of the Amplo gradient picker: CSS gradient parsing/serialization with per-stop format state, the gradient context and hook, and the pointer-driven parts both variants share — Bar (with its stop-editor slot), Overlay, Area, StopList, StopColor, angle/position pads and inputs, ShapeSwitcher, ReverseStops, RepeatingToggle, Presets, CssInput, and the anchor-only stop popover shell. Installed automatically as a dependency of `gradient-picker` (Base UI) and `gradient-picker-radix`; you do not normally add it directly.

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

Where it goes

stats

Visual style

gradient

Interaction

animated-countertooltip

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

Dependencies

Bundle weight · light
ui-primitives@base-ui/react
iconslucide-react

Registry dependencies

utilsbuttontogglehttps://amplo.ale.design/r/color-picker-engine.json

Files27 · source cached 10/8/2026, 2:20:41 AM

registry/new-york/ui/fill-picker/lib/gradient.ts
import type { OklchColor } from "./types";
import { formatColor, parseColor } from "./color";

export type GradientType = "linear" | "radial" | "conic";

export type GradientInterp =
  | "oklch"
  | "oklab"
  | "srgb"
  | "hsl"
  | "hsl-longer";

export interface GradientStop {
  color: OklchColor;
  /** 0..1 along the gradient axis. */
  position: number;
  /** Optional CSS midpoint hint, 0..1. */
  hint?: number;
  /**
   * Optional stable identity, honored in **controlled** mode only.
   *
   * Without it the picker reconciles an incoming `value` against its own
   * state by position and array index, which cannot tell two stops sharing a
   * position apart — reorder them externally and the colors swap between the
   * existing internal ids while the selection and per-stop format stay put.
   *
   * Supply an id per stop and reconciliation matches on identity instead, so
   * selection, per-stop color format, and colors all follow the right stop
   * through any reorder. Ids must be unique within the gradient; duplicates
   * are ignored and fall back to a generated id.
   *
   * Purely opt-in. Omit it and nothing changes — the picker reconciles by
   * position exactly as before and never adds an `id` to what it emits.
   *
   * Once you do supply ids, they round-trip: `onValueChange` echoes them back
   * (including on stops added inside the picker), so the ordinary
   * `onValueChange={g => setG(g)}` pattern keeps identity instead of losing
   * the tags on the first update.
   */
  id?: string;
}

export interface LinearGradient {
  type: "linear";
  /**
   * Degrees, 0..360. CSS gradient angle convention: 0deg = up, increases
   * clockwise. Always present. When `start` and `end` are set, this field
   * is kept in sync with `atan2(end - start)` so consumers reading `angle`
   * alone still get a meaningful value.
   */
  angle: number;
  /**
   * When true, `formatGradient` emits `repeating-linear-gradient(...)`. The
   * stop ramp repeats every (last - first) position span — typically used
   * for stripes / barberpole patterns. Optional: undefined = false.
   */
  repeating?: boolean;
  /**
   * Optional start endpoint of the gradient line, normalized 0..1 of the
   * gradient box in each axis. When both `start` and `end` are set, the
   * gradient is treated as "positioned" — the line is the segment between
   * them, and `formatGradient` emits CSS with stop positions adjusted so
   * the visual transition happens between these two points (the CSS
   * gradient line itself still passes through the box center because
   * `linear-gradient` has no way to express an offset line, but the
   * adjusted stop positions preserve the visual offset).
   *
   * When unset, the gradient behaves the legacy way: line passes through
   * the box center, direction determined by `angle` only.
   */
  start?: { x: number; y: number };
  /** Optional end endpoint. See `start`. */
  end?: { x: number; y: number };
  stops: GradientStop[];
  interp: GradientInterp;
}

/**
 * CSS radial-gradient extent keywords — the four values the spec allows in
 * the `<size>` slot. They describe how far the gradient ellipse reaches
 * from its center, measured to either a side or a corner of the gradient
 * box. Spec: https://www.w3.org/TR/css-images-3/#valdef-radial-gradient-extent-keyword
 */
export type RadialSizeKeyword =
  | "closest-side"
  | "closest-corner"
  | "farthest-side"
  | "farthest-corner";

export interface RadialGradient {
  type: "radial";
  shape: "circle" | "ellipse";
  /** Normalized 0..1 in each axis. */
  center: { x: number; y: number };
  size: RadialSizeKeyword;
  /**
   * Optional explicit radii. When set, takes precedence over `shape`+`size`
   * for CSS emission. `x` is a fraction of the gradient box width, `y` a
   * fraction of its height — matching the CSS `<length-percentage>{1,2}`
   * radial-gradient ending-shape syntax. When unset, the keyword form
   * (`shape size`) is emitted instead.
   *
   * Use this for **ellipse** shapes — pairs of percentages stay
   * container-relative on the consumer side.
   */
  radii?: { x: number; y: number };
  /**
   * Optional explicit circle radius in absolute pixels. Only meaningful
   * when `shape === "circle"`. When set, takes the highest precedence at
   * emit time and produces a `radial-gradient(<px>px at ...)` form — which
   * is the **only** way to get a CSS radial gradient that stays visually
   * circular regardless of the consumer container's aspect ratio. The
   * `<length-percentage>{2}` form always implies ellipse (CSS spec), so
   * even rx === ry-by-pixel from the picker box draws an ellipse when
   * pasted into a differently-shaped container.
   */
  radiusPx?: number;
  stops: GradientStop[];
  interp: GradientInterp;
  /** See `LinearGradient.repeating`. Emits `repeating-radial-gradient(...)`. */
  repeating?: boolean;
}

export interface ConicGradient {
  type: "conic";
  /** Degrees. */
  startAngle: number;
  /** Normalized 0..1 in each axis. */
  center: { x: number; y: number };
  stops: GradientStop[];
  interp: GradientInterp;
  /** See `LinearGradient.repeating`. Emits `repeating-conic-gradient(...)`. */
  repeating?: boolean;
}

export type Gradient = LinearGradient | RadialGradient | ConicGradient;

export type ColorFill = { kind: "color"; color: OklchColor };
export type GradientFill = { kind: "gradient"; gradient: Gradient };
export type Fill = ColorFill | GradientFill;

export const DEFAULT_LINEAR: LinearGradient = {
  type: "linear",
  angle: 90,
  interp: "oklch",
  stops: [
    { color: { l: 1, c: 0, h: 0, alpha: 1 }, position: 0 },
    { color: { l: 0, c: 0, h: 0, alpha: 1 }, position: 1 },
  ],
};

export const DEFAULT_RADIAL: RadialGradient = {
  type: "radial",
  shape: "circle",
  center: { x: 0.5, y: 0.5 },
  size: "farthest-corner",
  interp: "oklch",
  stops: [
    { color: { l: 1, c: 0, h: 0, alpha: 1 }, position: 0 },
    { color: { l: 0, c: 0, h: 0, alpha: 1 }, position: 1 },
  ],
};

export const DEFAULT_CONIC: ConicGradient = {
  type: "conic",
  startAngle: 0,
  center: { x: 0.5, y: 0.5 },
  interp: "oklch",
  stops: [
    { color: { l: 1, c: 0, h: 0, alpha: 1 }, position: 0 },
    { color: { l: 0, c: 0, h: 0, alpha: 1 }, position: 1 },
  ],
};

function trim(n: number, digits = 4): string {
  return Number(n.toFixed(digits)).toString();
}

const formatStopColor = (c: OklchColor): string => formatColor(c, "oklch");

function formatStops(stops: GradientStop[]): string {
  const parts: string[] = [];
  stops.forEach((s, i) => {
    // A hint on stop i is the midpoint *between* stop i-1 and stop i —
    // emit it before this stop's color declaration.
    if (s.hint !== undefined && i > 0) {
      parts.push(`${trim(s.hint * 100)}%`);
    }
    parts.push(`${formatStopColor(s.color)} ${trim(s.position * 100)}%`);
  });
  return parts.join(", ");
}

function formatInterp(interp: GradientInterp): string {
  if (interp === "hsl-longer") return "hsl longer hue";
  return interp;
}

/**
 * Derive a CSS gradient angle (0deg = up, increases clockwise) from two
 * box-normalized points. Returns undefined when either point is missing or
 * the two coincide.
 */
export function angleFromPoints(
  start: { x: number; y: number } | undefined,
  end: { x: number; y: number } | undefined,
): number | undefined {
  if (!start || !end) return undefined;
  const dx = end.x - start.x;
  // Box y axis points down in screen coords, but CSS gradient angle is
  // measured with 0deg = up. Flip dy so the formula matches the visual.
  const dy = -(end.y - start.y);
  if (dx === 0 && dy === 0) return undefined;
  // atan2(x, y) with CSS convention: angle = arctan2(dx, dy) gives 0 = up,
  // increases clockwise. Same convention used by AnglePad and Area.
  const deg = (Math.atan2(dx, dy) * 180) / Math.PI;
  return ((deg % 360) + 360) % 360;
}

/**
 * Project a linear gradient's `start` and `end` endpoints onto the CSS
 * gradient line (the centered line implied by the derived angle). Returns
 * `null` when the segment has zero length and no projection is meaningful.
 *
 * The CSS gradient line length depends on the actual box dimensions; this
 * projection assumes a square unit box, so the magnitudes of the returned
 * `startProj` / `endProj` are an approximation in stretched containers.
 * The direction is always correct.
 */
export function gradientLineProjection(
  start: { x: number; y: number },
  end: { x: number; y: number },
): { startProj: number; endProj: number } | null {
  const dx = end.x - start.x;
  const dy = end.y - start.y;
  const len = Math.hypot(dx, dy);
  if (len === 0) return null;
  const ux = dx / len;
  const uy = dy / len;
  const cssLen = Math.abs(ux) + Math.abs(uy);
  const project = (p: { x: number; y: number }) =>
    0.5 + ((p.x - 0.5) * ux + (p.y - 0.5) * uy) / cssLen;
  return { startProj: project(start), endProj: project(end) };
}

/**
 * Map an authored stop position (0..1 along the [start, end] segment) into
 * its visible position along the CSS gradient line. Identity when the
 * segment has zero length or either endpoint is missing.
 */
export function projectStopPosition(
  authoredPos: number,
  start: { x: number; y: number } | undefined,
  end: { x: number; y: number } | undefined,
): number {
  if (!start || !end) return authoredPos;
  const proj = gradientLineProjection(start, end);
  if (!proj) return authoredPos;
  return proj.startProj + (proj.endProj - proj.startProj) * authoredPos;
}

/**
 * Inverse of `projectStopPosition`: take a position along the CSS gradient
 * line and return the corresponding authored position. Useful for
 * converting a pointer position on the Bar back into the value that should
 * be written to `stop.position`. Returns the input unchanged when there's
 * no segment to project onto.
 */
export function reverseProjectStopPosition(
  displayedPos: number,
  start: { x: number; y: number } | undefined,
  end: { x: number; y: number } | undefined,
): number {
  if (!start || !end) return displayedPos;
  const proj = gradientLineProjection(start, end);
  if (!proj) return displayedPos;
  const span = proj.endProj - proj.startProj;
  if (span === 0) return 0;
  return (displayedPos - proj.startProj) / span;
}

/**
 * Re-map a list of stop positions so that the gradient transition happens
 * between `start` and `end` along the centered CSS gradient line. Used by
 * `formatGradient` for the positioned linear case, and by the Bar /
 * StopList parts to mirror that projection in their UI.
 */
export function adjustStopsForEndpoints(
  stops: GradientStop[],
  start: { x: number; y: number },
  end: { x: number; y: number },
): GradientStop[] {
  const proj = gradientLineProjection(start, end);
  if (!proj) return stops;
  const { startProj, endProj } = proj;
  return stops.map((s) => ({
    ...s,
    position: startProj + (endProj - startProj) * s.position,
    hint:
      s.hint === undefined
        ? undefined
        : startProj + (endProj - startProj) * s.hint,
  }));
}

export function formatGradient(g: Gradient): string {
  const interp = `in ${formatInterp(g.interp)}`;
  const prefix = g.repeating ? "repeating-" : "";
  if (g.type === "linear") {
    if (g.start && g.end) {
      const derivedAngle = angleFromPoints(g.start, g.end);
      // If start and end coincide (zero-length line), there's no meaningful
      // direction — fall through to the plain angle form.
      if (derivedAngle !== undefined) {
        const adjusted = adjustStopsForEndpoints(g.stops, g.start, g.end);
        return `${prefix}linear-gradient(${interp} ${trim(derivedAngle)}deg, ${formatStops(adjusted)})`;
      }
    }
    return `${prefix}linear-gradient(${interp} ${trim(g.angle)}deg, ${formatStops(g.stops)})`;
  }
  if (g.type === "radial") {
    const center = `at ${trim(g.center.x * 100)}% ${trim(g.center.y * 100)}%`;
    // Precedence at emit time:
    //   1. shape=circle + radiusPx → `<px>px` (single length forces circle,
    //      stays a true circle in any consumer container)
    //   2. radii → `<rx>% <ry>%` (always renders as an ellipse, scales with
    //      container width / height respectively)
    //   3. keyword form → `shape size` (CSS default, computed live by the
    //      renderer relative to the container + center)
    let endingShape: string;
    if (g.shape === "circle" && g.radiusPx !== undefined) {
      endingShape = `${trim(g.radiusPx)}px`;
    } else if (g.shape === "ellipse" && g.radii) {
      // The `% %` pair always implies ellipse in CSS, so it must never be
      // emitted for shape: "circle" — stale radii from an earlier ellipse
      // edit would silently flip the rendered shape.
      endingShape = `${trim(g.radii.x * 100)}% ${trim(g.radii.y * 100)}%`;
    } else {
      endingShape = `${g.shape} ${g.size}`;
    }
    return `${prefix}radial-gradient(${endingShape} ${center} ${interp}, ${formatStops(g.stops)})`;
  }
  // conic
  const center = `at ${trim(g.center.x * 100)}% ${trim(g.center.y * 100)}%`;
  return `${prefix}conic-gradient(from ${trim(g.startAngle)}deg ${center} ${interp}, ${formatStops(g.stops)})`;
}

// ---------------------------------------------------------------------------
// parseGradient — inverse of formatGradient
// ---------------------------------------------------------------------------

const FN_RE = /^(repeating-)?(linear|radial|conic)-gradient\((.*)\)$/is;

/** Split on commas that are not nested inside parentheses. */
function splitTopLevel(input: string): string[] {
  const out: string[] = [];
  let depth = 0;
  let buf = "";
  for (const ch of input) {
    if (ch === "(") depth++;
    else if (ch === ")") depth--;
    if (ch === "," && depth === 0) {
      out.push(buf.trim());
      buf = "";
    } else {
      buf += ch;
    }
  }
  if (buf.trim()) out.push(buf.trim());
  return out;
}

/**
 * Extract `in <space>[ longer hue]` from anywhere within a string.
 * Returns the parsed interp and the string with the `in …` clause removed.
 */
function extractInterp(s: string): { interp: GradientInterp; rest: string } {
  // `in <space> [<hue-method> hue]` — only `hsl longer hue` has a model
  // value; other hue methods are accepted and dropped rather than left
  // in `rest` to poison the header parse.
  const m = s.match(
    /\bin\s+([a-z0-9-]+)(?:\s+(shorter|longer|increasing|decreasing)\s+hue)?\b/i,
  );
  if (!m) return { interp: "oklch", rest: s };
  const space = m[1].toLowerCase();
  const longer = m[2]?.toLowerCase() === "longer";
  let interp: GradientInterp = "oklch";
  if (space === "hsl") interp = longer ? "hsl-longer" : "hsl";
  else if (space === "oklab") interp = "oklab";
  else if (space === "srgb") interp = "srgb";
  return { interp, rest: s.replace(m[0], "").trim() };
}

function parseStops(parts: string[]): GradientStop[] | null {
  const raw: Array<{
    color: OklchColor;
    position: number | null;
    hint?: number;
  }> = [];
  // A hint belongs to the stop that FOLLOWS it (formatStops emits stop i's
  // hint between stop i-1 and stop i), so stash it until the next color stop.
  let pendingHint: number | undefined;
  for (const p of parts) {
    // Bare percentage hint: `30%`
    const hintMatch = p.match(/^(-?\d+(?:\.\d+)?)%$/);
    if (hintMatch) {
      if (raw.length === 0) return null; // hint can't lead
      if (pendingHint !== undefined) return null; // two hints in a row
      pendingHint = parseFloat(hintMatch[1]) / 100;
      continue;
    }
    // Color + up to two positions: `oklch(…)`, `oklch(…) 50%`, or the
    // hard-stop shorthand `red 0% 50%`, which is two stops of one color.
    const two = p.match(/^(.*?)\s+(-?\d+(?:\.\d+)?)%\s+(-?\d+(?:\.\d+)?)%$/);
    const one = two ? null : p.match(/^(.*?)\s+(-?\d+(?:\.\d+)?)%$/);
    const colorStr = (two ?? one)?.[1].trim() ?? p;
    const positions: Array<number | null> = two
      ? [parseFloat(two[2]) / 100, parseFloat(two[3]) / 100]
      : [one ? parseFloat(one[2]) / 100 : null];
    const color = parseColor(colorStr);
    if (!color) return null;
    positions.forEach((position, k) => {
      raw.push({
        color: k === 0 ? color : { ...color },
        position,
        ...(k === 0 && pendingHint !== undefined ? { hint: pendingHint } : {}),
      });
    });
    pendingHint = undefined;
  }
  if (pendingHint !== undefined) return null; // hint can't trail
  if (raw.length === 0) return null;

  // CSS Images 3 §3.4.3 position fixup:
  //  1. First/last stops without a position default to 0% / 100%.
  //  2. Explicit positions may never decrease — clamp to the running max.
  //  3. Runs of unpositioned stops are spaced evenly between their
  //     positioned neighbors.
  if (raw[0].position === null) raw[0].position = 0;
  if (raw[raw.length - 1].position === null) raw[raw.length - 1].position = 1;
  let runningMax = raw[0].position as number;
  for (const s of raw) {
    if (s.position !== null) {
      s.position = Math.max(s.position, runningMax);
      runningMax = s.position;
    }
  }
  let i = 0;
  while (i < raw.length) {
    if (raw[i].position !== null) {
      i++;
      continue;
    }
    let j = i;
    while (raw[j].position === null) j++;
    const prev = raw[i - 1].position as number;
    const next = raw[j].position as number;
    const count = j - i + 1; // gaps between prev and next
    for (let k = i; k < j; k++) {
      raw[k].position = prev + ((next - prev) * (k - i + 1)) / count;
    }
    i = j;
  }
  return raw.map((s) => ({
    color: s.color,
    position: s.position as number,
    ...(s.hint !== undefined ? { hint: s.hint } : {}),
  }));
}

/**
 * Map a CSS `to <side-or-corner>` linear direction to a gradient angle.
 * Corners use the square-box angles (45/135/225/315) — the picker's model
 * only stores an angle, so the CSS "magic corner" aspect-dependence is
 * intentionally approximated. Returns null for invalid pairs
 * (`to left right`).
 */
function sideOrCornerAngle(
  a: string,
  b: string | undefined,
): number | null {
  const set = new Set([a.toLowerCase(), ...(b ? [b.toLowerCase()] : [])]);
  if (b && set.size !== 2) return null;
  const has = (s: string) => set.has(s);
  if ((has("top") && has("bottom")) || (has("left") && has("right"))) {
    return null;
  }
  if (set.size === 1) {
    if (has("top")) return 0;
    if (has("right")) return 90;
    if (has("bottom")) return 180;
    return 270; // left
  }
  if (has("top") && has("right")) return 45;
  if (has("bottom") && has("right")) return 135;
  if (has("bottom") && has("left")) return 225;
  return 315; // top left
}

const NUM_RE_SRC = String.raw`-?\d+(?:\.\d+)?`;
const PCT_RE = new RegExp(`^(${NUM_RE_SRC})%$`);
const PX_RE = new RegExp(`^(${NUM_RE_SRC})px$`, "i");
const ANGLE_RE = new RegExp(`^(${NUM_RE_SRC})(deg|turn|rad|grad)?$`, "i");
const RADIAL_SIZES: readonly string[] = [
  "closest-side",
  "closest-corner",
  "farthest-side",
  "farthest-corner",
] satisfies RadialSizeKeyword[];

const wrapDeg = (d: number) => ((d % 360) + 360) % 360;

/** CSS `<angle>` → degrees in [0, 360). Unitless is accepted only for `0`. */
function parseAngle(s: string): number | null {
  const m = s.trim().match(ANGLE_RE);
  if (!m) return null;
  const n = parseFloat(m[1]);
  switch (m[2]?.toLowerCase()) {
    case "deg":
      return wrapDeg(n);
    case "turn":
      return wrapDeg(n * 360);
    case "rad":
      return wrapDeg((n * 180) / Math.PI);
    case "grad":
      return wrapDeg(n * 0.9);
    default:
      return n === 0 ? 0 : null;
  }
}

/**
 * CSS `<position>` (one or two tokens: percentages and side keywords) →
 * 0..1 fractions. Pixel offsets aren't representable in the model → null.
 */
function parsePosition(s: string): { x: number; y: number } | null {
  const toks = s.trim().toLowerCase().split(/\s+/).filter(Boolean);
  const X: Record<string, number> = { left: 0, center: 0.5, right: 1 };
  const Y: Record<string, number> = { top: 0, center: 0.5, bottom: 1 };
  const pct = (t: string) => {
    const m = t.match(PCT_RE);
    return m ? parseFloat(m[1]) / 100 : undefined;
  };
  if (toks.length === 1) {
    const [t] = toks;
    const p = pct(t);
    if (p !== undefined) return { x: p, y: 0.5 };
    if (t in X) return { x: X[t], y: 0.5 };
    if (t in Y) return { x: 0.5, y: Y[t] };
    return null;
  }
  if (toks.length !== 2) return null;
  let [a, b] = toks;
  // Keyword pairs may come vertical-first (`top right`).
  if ((a in Y && !(a in X)) || (b in X && !(b in Y))) [a, b] = [b, a];
  const x = pct(a) ?? X[a];
  const y = pct(b) ?? Y[b];
  return x === undefined || y === undefined ? null : { x, y };
}

interface RadialHeader {
  shape: "circle" | "ellipse";
  size: RadialSizeKeyword;
  center: { x: number; y: number };
  radii?: { x: number; y: number };
  radiusPx?: number;
}

/**
 * Parse a radial header (`circle farthest-corner at 50% 50%`, `48% 30%`,
 * `268px at top`, …). Returns null on any unrecognized token so invalid
 * input is rejected instead of silently becoming the default gradient.
 */
function parseRadialHeader(head: string): RadialHeader | null {
  const [beforeAt, afterAt, ...extra] = head.split(/\bat\b/i);
  if (extra.length > 0) return null;
  let center = { x: 0.5, y: 0.5 };
  if (afterAt !== undefined) {
    const pos = parsePosition(afterAt);
    if (!pos) return null;
    center = pos;
  }
  let shape: "circle" | "ellipse" | undefined;
  let size: RadialSizeKeyword | undefined;
  const lengths: string[] = [];
  for (const tok of beforeAt.trim().toLowerCase().split(/\s+/).filter(Boolean)) {
    if (tok === "circle" || tok === "ellipse") {
      if (shape) return null;
      shape = tok;
    } else if (RADIAL_SIZES.includes(tok)) {
      if (size) return null;
      size = tok as RadialSizeKeyword;
    } else if (PX_RE.test(tok) || PCT_RE.test(tok)) {
      lengths.push(tok);
    } else {
      return null;
    }
  }
  if (lengths.length > 2) return null;
  const header: RadialHeader = {
    shape: shape ?? "ellipse",
    size: size ?? "farthest-corner",
    center,
  };
  if (lengths.length === 1) {
    // A single `<length>` is only valid as a circle radius; a lone
    // percentage isn't valid CSS.
    const px = lengths[0].match(PX_RE);
    if (!px) return null;
    header.radiusPx = parseFloat(px[1]);
    header.shape = "circle";
  } else if (lengths.length === 2) {
    // Only the percentage pair formatGradient emits maps onto the model;
    // raw lengths (`100px 80px`) fall through to the keyword defaults
    // rather than being silently rescaled.
    const [rx, ry] = lengths.map((t) => t.match(PCT_RE));
    if (rx && ry) {
      header.radii = { x: parseFloat(rx[1]) / 100, y: parseFloat(ry[1]) / 100 };
    }
  }
  return header;
}

interface ConicHeader {
  startAngle: number;
  center: { x: number; y: number };
}

/** Parse a conic header (`from 90deg at 50% 50%`). Null on junk. */
function parseConicHeader(head: string): ConicHeader | null {
  const [beforeAt, afterAt, ...extra] = head.split(/\bat\b/i);
  if (extra.length > 0) return null;
  let center = { x: 0.5, y: 0.5 };
  if (afterAt !== undefined) {
    const pos = parsePosition(afterAt);
    if (!pos) return null;
    center = pos;
  }
  let startAngle = 0;
  const from = beforeAt.trim();
  if (from) {
    const m = from.match(/^from\s+(\S+)$/i);
    const angle = m ? parseAngle(m[1]) : null;
    if (angle === null) return null;
    startAngle = angle;
  }
  return { startAngle, center };
}

export function parseGradient(input: string): Gradient | null {
  const trimmed = input.trim();
  const m = trimmed.match(FN_RE);
  if (!m) return null;

  const repeating = !!m[1];
  const type = m[2].toLowerCase() as GradientType;
  const parts = splitTopLevel(m[3]);
  if (parts.length === 0) return null;

  if (type === "linear") {
    // parts[0] for formatGradient output: "in oklch 90deg"
    // For a hand-written gradient without interp: "90deg" or absent (just stops)
    const { interp, rest } = extractInterp(parts[0]);
    let angle = 180; // CSS default
    let stopParts = parts.slice(1);

    const parsedAngle = parseAngle(rest);
    const toMatch = rest.match(
      /^to\s+(top|bottom|left|right)(?:\s+(top|bottom|left|right))?$/i,
    );
    if (parsedAngle !== null) {
      angle = parsedAngle;
    } else if (toMatch) {
      const dir = sideOrCornerAngle(toMatch[1], toMatch[2]);
      if (dir === null) return null; // e.g. "to left right"
      angle = dir;
    } else if (rest.length > 0) {
      // rest wasn't a direction — treat it as the first stop
      stopParts = [rest, ...stopParts];
    }

    const stops = parseStops(stopParts);
    if (!stops) return null;
    return {
      type: "linear",
      angle,
      interp,
      stops,
      ...(repeating ? { repeating: true } : {}),
    };
  }

  // radial / conic: a header-less gradient (`radial-gradient(red, blue)`)
  // starts straight with its stops, so when parts[0] isn't a valid header
  // and carried no `in <space>` clause, re-read it as the first stop.
  const { interp, rest } = extractInterp(parts[0]);
  const hadInterp = rest !== parts[0];
  const header =
    type === "radial" ? parseRadialHeader(rest) : parseConicHeader(rest);
  if (!header && hadInterp) return null;
  const stops = parseStops(header ? parts.slice(1) : parts);
  if (!stops) return null;

  if (type === "radial") {
    const h = (header as RadialHeader | null) ?? {
      shape: "ellipse",
      size: "farthest-corner",
      center: { x: 0.5, y: 0.5 },
    };
    return {
      type: "radial",
      shape: h.shape,
      size: h.size,
      center: h.center,
      ...(h.radii ? { radii: h.radii } : {}),
      ...(h.radiusPx !== undefined ? { radiusPx: h.radiusPx } : {}),
      interp,
      stops,
      ...(repeating ? { repeating: true } : {}),
    };
  }

  const h = (header as ConicHeader | null) ?? {
    startAngle: 0,
    center: { x: 0.5, y: 0.5 },
  };
  return {
    type: "conic",
    startAngle: h.startAngle,
    center: h.center,
    interp,
    stops,
    ...(repeating ? { repeating: true } : {}),
  };
}

// ---------------------------------------------------------------------------
// parseFill / formatFill — discriminated union of Color | Gradient
// ---------------------------------------------------------------------------

export function formatFill(f: Fill): string {
  if (f.kind === "color") return formatColor(f.color, "oklch");
  return formatGradient(f.gradient);
}

export function parseFill(input: string): Fill | null {
  const g = parseGradient(input);
  if (g) return { kind: "gradient", gradient: g };
  const c = parseColor(input);
  if (c) return { kind: "color", color: c };
  return null;
}

// ---------------------------------------------------------------------------
// sampleStopsAt — interpolate a stop color at an arbitrary 0..1 position
// ---------------------------------------------------------------------------

const lerp = (a: number, b: number, t: number) => a + (b - a) * t;

// Shortest-path hue lerp on a 0..360 circle. Matches what users perceive in
// an OKLCH-rendered gradient between two adjacent stops.
const ACHROMATIC_C = 1e-4;

function lerpHue(a: number, b: number, t: number): number {
  let d = b - a;
  if (d > 180) d -= 360;
  else if (d < -180) d += 360;
  return ((a + d * t) % 360 + 360) % 360;
}

/**
 * Returns the OKLCH color at `position` (0..1) along a sorted-by-position
 * stop list. Used by Bar so click-to-add picks the color the user sees.
 * Interpolation is done in OKLCH regardless of the gradient's `interp`
 * setting — the stops themselves are canonical OKLCH and that gives a
 * perceptually sensible pick even when the visual paint uses sRGB/HSL.
 */
export function sampleStopsAt(
  stops: GradientStop[],
  position: number,
): OklchColor {
  if (stops.length === 0) return { l: 0.5, c: 0, h: 0, alpha: 1 };
  const sorted = [...stops].sort((a, b) => a.position - b.position);
  if (position <= sorted[0].position) return { ...sorted[0].color };
  const last = sorted[sorted.length - 1];
  if (position >= last.position) return { ...last.color };
  let i = 0;
  while (i < sorted.length - 1 && sorted[i + 1].position < position) i++;
  const a = sorted[i];
  const b = sorted[i + 1];
  const span = b.position - a.position;
  let t = span === 0 ? 0 : (position - a.position) / span;
  // CSS midpoint hint: the hint (stored on the following stop, in absolute
  // axis coordinates like `position`) marks where the blend reaches 50%.
  // Browsers warp progress with t^(log(.5)/log(h)) so t = h lands at 0.5 —
  // apply the same curve so click-to-add picks the color the user sees.
  if (b.hint !== undefined && span > 0) {
    const h = (b.hint - a.position) / span;
    if (h > 0 && h < 1) {
      t = Math.pow(t, Math.log(0.5) / Math.log(h));
    }
  }
  // An achromatic stop's hue is powerless; CSS Color 4 treats it as missing
  // and borrows the other endpoint's hue instead of sweeping through 0°.
  const ha = a.color.c <= ACHROMATIC_C ? b.color.h : a.color.h;
  const hb = b.color.c <= ACHROMATIC_C ? a.color.h : b.color.h;
  return {
    l: lerp(a.color.l, b.color.l, t),
    c: lerp(a.color.c, b.color.c, t),
    h: lerpHue(ha, hb, t),
    alpha: lerp(a.color.alpha, b.color.alpha, t),
  };
}
registry/new-york/ui/fill-picker/lib/gradient-options.ts
import type {
  GradientInterp,
  GradientType,
  RadialSizeKeyword,
} from "./gradient";

/**
 * Option metadata shared by both switcher variants (classic and Base UI).
 * Living here — not copy-pasted into each tree — means a label or tooltip
 * edit can't desync the two product surfaces.
 */

export const GRADIENT_TYPE_OPTIONS: {
  value: GradientType;
  label: string;
}[] = [
  { value: "linear", label: "Linear" },
  { value: "radial", label: "Radial" },
  { value: "conic", label: "Conic" },
];

/**
 * Selectable interpolation spaces. Each maps to the CSS Color 4
 * `<gradient> in <space>` clause emitted by `formatGradient`. The
 * description is surfaced via the ⓘ icon next to each option, so users
 * hovering an option in the open menu see *why* they'd pick it.
 */
export const GRADIENT_INTERP_OPTIONS: {
  value: GradientInterp;
  label: string;
  description: string;
}[] = [
  {
    value: "oklch",
    label: "OKLCH",
    description:
      "Perceptually uniform polar space. Smooth hue arcs, no muddy mid-tones. Recommended default.",
  },
  {
    value: "oklab",
    label: "OKLab",
    description:
      "Perceptual but cartesian — a straight line through OK space. Smooth lightness, no hue rotation.",
  },
  {
    value: "srgb",
    label: "sRGB",
    description:
      "Legacy browser default. Mixes in gamma-encoded sRGB; often grays through the middle of two saturated colors.",
  },
  {
    value: "hsl",
    label: "HSL",
    description:
      "Walks the hue circle the shorter way between the two stops.",
  },
  {
    value: "hsl-longer",
    label: "HSL (longer hue)",
    description:
      "Walks the hue circle the longer way — produces a full rainbow sweep between two stops.",
  },
];

/**
 * The four CSS `<size>` keywords for radial gradients, each with a
 * one-sentence hover explanation surfaced via the ⓘ icon.
 *
 * https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/radial-gradient#size
 */
export const RADIAL_SIZE_OPTIONS: {
  value: RadialSizeKeyword;
  description: string;
}[] = [
  {
    value: "closest-side",
    description:
      "Ends at the side of the box closest to the center (the shortest reachable edge).",
  },
  {
    value: "closest-corner",
    description:
      "Ends at the corner of the box closest to the center — passes through the nearest corner.",
  },
  {
    value: "farthest-side",
    description:
      "Ends at the side of the box farthest from the center (the longest reachable edge).",
  },
  {
    value: "farthest-corner",
    description:
      "Default. Ends at the corner of the box farthest from the center — gradient covers the entire box.",
  },
];
registry/new-york/ui/fill-picker/contexts/gradient.ts
"use client";

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

export const GradientPickerContext =
  React.createContext<GradientPickerState | null>(null);

/**
 * Renders the per-stop color editor for `<GradientPicker.Bar editOnClick>`.
 *
 * The Bar owns the *interaction* (which stop is being edited, and when a tap
 * counts as an edit) but not the editor's UI dialect — that ships per variant
 * so the shared Bar can live in a dialect-clean engine item. Each barrel
 * injects its own default through `<Root stopEditor>`; consumers can override
 * it with the same prop.
 *
 * `children` is the stop handle: the renderer must render it (that's the
 * element the popover anchors to and the element the user drags).
 *
 * Called as a plain function inside the parts' stop `.map()`, not mounted as
 * JSX — so it must *return* a component element and never call hooks itself,
 * or the hook order breaks the moment a stop is added or removed.
 */
export type GradientStopEditorRenderer = (props: {
  stopId: string;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /** The stop handle element to anchor/wrap. */
  children: React.ReactNode;
}) => React.ReactNode;

/**
 * Carried separately from `GradientPickerContext` on purpose: the gradient
 * state is also provided by `<FillPicker.Pane mode="gradient">`, which builds
 * it straight from `useGradientPicker` and never sees a `stopEditor` prop.
 * A sibling context lets a barrel inject the default once, above either
 * provider, without threading the renderer through the headless state.
 */
export const GradientStopEditorContext =
  React.createContext<GradientStopEditorRenderer | null>(null);

/** The injected stop editor, or `null` when no barrel/consumer provided one. */
export function useGradientStopEditor(): GradientStopEditorRenderer | null {
  return React.useContext(GradientStopEditorContext);
}

export function useGradientPickerContext(): GradientPickerState {
  const ctx = React.useContext(GradientPickerContext);
  if (!ctx) {
    throw new Error(
      "GradientPicker.* parts must be rendered inside <GradientPicker.Root> " +
        "or <FillPicker.Pane mode=\"gradient\">",
    );
  }
  return ctx;
}
registry/new-york/ui/fill-picker/hooks/use-gradient-picker.ts
"use client";

import * as React from "react";
import {
  DEFAULT_LINEAR,
  DEFAULT_RADIAL,
  DEFAULT_CONIC,
  angleFromPoints,
  formatGradient,
  type Gradient,
  type GradientInterp,
  type GradientStop,
  type GradientType,
  type LinearGradient,
  type RadialGradient,
  type RadialSizeKeyword,
  type ConicGradient,
} from "../lib/gradient";
import type { ColorFormat, OklchColor } from "../lib/types";

let __idCounter = 0;
const nextId = () => `s${++__idCounter}`;

// ---- Internal types --------------------------------------------------------

interface InternalStop extends GradientStop {
  id: string;
}

// We store gradient config and stops separately so TypeScript always sees
// stops as InternalStop[] without needing to narrow through a discriminated union.
interface InternalState {
  // The gradient without stops — used as a base for type-specific properties.
  // We keep the full gradient object and overlay stops separately.
  gradient: Gradient;
  stops: InternalStop[];
}

// ---- Helpers ---------------------------------------------------------------

function attachIds(g: Gradient): InternalState {
  // Sort on entry. `formatStops` emits stops in array order and CSS requires
  // non-decreasing positions, so an out-of-order (but perfectly type-legal)
  // stop array arriving via `defaultValue`, a controlled `value`, or
  // `setGradient` would emit CSS the browser silently clamps into a flat
  // ramp. The mutating setters (addStop / moveStop / reverseStops) already
  // route through `sortByPosition` for exactly this reason — the entry points
  // did not. `gradient.stops` is never read for stop data (toPublicGradient
  // always rebuilds it from `stops`), so sorting the id-bearing array is
  // sufficient.
  const used = new Set<string>();
  return {
    gradient: g,
    stops: [...g.stops]
      .sort((a, b) => a.position - b.position)
      .map((s) => {
        // Honor a consumer-supplied id so controlled reconciliation can match
        // on identity. Duplicates would defeat the point (and could collide
        // with a generated id), so anything already taken falls back.
        const id = s.id && !used.has(s.id) ? s.id : nextId();
        used.add(id);
        return { ...s, id };
      }),
  };
}

/**
 * True when the caller is tracking stops by id — i.e. every stop of the
 * gradient they handed us carries one. Partial id sets don't count: the
 * identity match is all-or-nothing, so half-tagged stops would silently fall
 * back to position matching anyway.
 */
function hasStopIds(g: Gradient): boolean {
  return g.stops.length > 0 && g.stops.every((s) => !!s.id);
}

/**
 * `withIds` echoes ids back out. Off by default so a caller who never opted
 * into `GradientStop.id` keeps receiving exactly the object shape they always
 * have. Once they *have* opted in, stripping ids would defeat the feature for
 * the common `onValueChange={g => setG(g)}` pattern: the stored gradient would
 * come back untagged on the next render and fall straight back to position
 * matching. Stops added inside the picker are echoed too — a half-tagged set
 * is the same as none.
 */
function toPublicGradient(s: InternalState, withIds: boolean): Gradient {
  const stops: GradientStop[] = withIds
    ? s.stops.map((stop) => ({ ...stop }))
    : s.stops.map(({ id: _id, ...rest }) => rest);
  return { ...s.gradient, stops } as Gradient;
}

function sortByPosition(stops: InternalStop[]): InternalStop[] {
  return [...stops].sort((a, b) => a.position - b.position);
}

const clamp01 = (n: number) => Math.max(0, Math.min(1, n));

function defaultsForType(type: GradientType): Gradient {
  if (type === "linear") return DEFAULT_LINEAR;
  if (type === "radial") return DEFAULT_RADIAL;
  return DEFAULT_CONIC;
}

// ---- Public API types -------------------------------------------------------

export interface UseGradientPickerProps {
  value?: Gradient;
  defaultValue?: Gradient;
  onValueChange?: (gradient: Gradient, css: string) => void;
  /**
   * Initial display format applied to every stop on mount. Each stop
   * tracks its **own** format from then on — changing format in one
   * stop's popover (or in `<GradientPicker.StopColor>`'s FormatSwitcher,
   * which is bound to the selected stop) only updates that stop's row
   * text. Defaults to `oklch` (lossless across the full P3/Rec.2020
   * gamut stop colors can occupy).
   */
  defaultStopColorFormat?: ColorFormat;
}

export interface GradientPickerState {
  gradient: Gradient;
  stops: InternalStop[];
  selectedStopId: string;
  selectedStop: InternalStop | null;
  setGradient: (next: Gradient) => void;
  setType: (type: GradientType) => void;
  setAngle: (angleDeg: number) => void;
  /**
   * Set the start endpoint of a linear gradient (normalized 0..1 of the
   * gradient box). Promotes the gradient to "positioned" mode — when both
   * `start` and `end` are set, the gradient is treated as a line between
   * them rather than an angle-only construct. `angle` is kept in sync with
   * the derived direction. Passing `undefined` clears the override and
   * returns the gradient to the legacy angle-only behavior.
   */
  setLinearStart: (xy: { x: number; y: number } | undefined) => void;
  /** Set the end endpoint of a linear gradient. See `setLinearStart`. */
  setLinearEnd: (xy: { x: number; y: number } | undefined) => void;
  setStartAngle: (angleDeg: number) => void;
  setCenter: (xy: { x: number; y: number }) => void;
  setInterp: (interp: GradientInterp) => void;
  /**
   * Toggle the `repeating` flag on the active gradient. When true,
   * `formatGradient` emits `repeating-<type>-gradient(...)`. Stops are
   * unaffected — the stop ramp simply tiles instead of stretching to fill
   * the box.
   */
  setRepeating: (repeating: boolean) => void;
  setRadialShape: (shape: "circle" | "ellipse") => void;
  setRadialSize: (size: RadialSizeKeyword) => void;
  /**
   * Set explicit numeric radii on the active radial gradient. Each value is
   * a 0..1 fraction of the gradient box (x→width, y→height). Passing
   * `undefined` clears the explicit override and falls back to the
   * keyword-based size from `setRadialShape` / `setRadialSize`. Use this
   * for ellipse-shape radials.
   */
  setRadii: (radii: { x: number; y: number } | undefined) => void;
  /**
   * Set the explicit circle radius in absolute pixels. Only meaningful
   * when `shape === "circle"` — produces a CSS `radial-gradient(<px>px
   * at ...)` form, which is the only way to keep the gradient visually
   * circular in any consumer container (the percentage pair form always
   * implies ellipse). Passing `undefined` clears the override.
   */
  setRadiusPx: (px: number | undefined) => void;
  /**
   * Latest reported width of the visual gradient box (px). Set by
   * `<GradientPicker.Area>` via its `ResizeObserver`. Used by parts that
   * want to display radius as a percentage of the picker box (e.g. the
   * radius input on `<GradientPicker.RadiusInput>`) — they read this to
   * convert between the absolute-px storage and a friendlier % display.
   * `null` when no Area is currently mounted.
   */
  containerWidth: number | null;
  setContainerWidth: (width: number | null) => void;
  selectStop: (id: string) => void;
  addStop: (position: number, color?: OklchColor) => string;
  removeStop: (id: string) => void;
  moveStop: (id: string, position: number) => void;
  setStopColor: (id: string, color: OklchColor) => void;
  setStopHint: (id: string, hint: number | undefined) => void;
  reverseStops: () => void;
  /**
   * Per-stop display format. Each row tracks its own — changing format
   * in one stop's popover (or in `<GradientPicker.StopColor>`'s
   * FormatSwitcher, which is bound to the selected stop) only updates
   * that stop. Stops without an explicit entry fall back to
   * `defaultStopColorFormat` (default `oklch`).
   */
  getStopColorFormat: (id: string) => ColorFormat;
  setStopColorFormat: (id: string, format: ColorFormat) => void;
}

// ---- Hook ------------------------------------------------------------------

export function useGradientPicker(
  props: UseGradientPickerProps = {},
): GradientPickerState {
  const { value, defaultValue, onValueChange, defaultStopColorFormat } = props;
  const isControlled = value !== undefined;
  const fallbackFormat: ColorFormat = defaultStopColorFormat ?? "oklch";
  const [stopColorFormats, setStopColorFormats] = React.useState<
    Record<string, ColorFormat>
  >({});
  const getStopColorFormat = React.useCallback(
    (id: string): ColorFormat => stopColorFormats[id] ?? fallbackFormat,
    [stopColorFormats, fallbackFormat],
  );
  const setStopColorFormat = React.useCallback(
    (id: string, format: ColorFormat) =>
      setStopColorFormats((prev) =>
        prev[id] === format ? prev : { ...prev, [id]: format },
      ),
    [],
  );

  const [internal, setInternal] = React.useState<InternalState>(() =>
    attachIds(value ?? defaultValue ?? DEFAULT_LINEAR),
  );

  // Whether the caller opted into id tracking. State drives render output;
  // the mirrored ref lets `apply` handle chained mutations synchronously.
  const initialIdTracking = hasStopIds(
    value ?? defaultValue ?? DEFAULT_LINEAR,
  );
  const [idTracked, setIdTracked] = React.useState(initialIdTracking);
  const idTrackedRef = React.useRef(initialIdTracking);
  const [selectedStopId, setSelectedStopId] = React.useState<string>(
    () => internal.stops[0]?.id ?? "",
  );

  // Track the last gradient we emitted upward so the controlled-sync path can
  // ignore echoes. Seed with the initial controlled value so the *first* sync
  // is treated as an echo of our own initial state.
  const [lastEmitted, setLastEmitted] = React.useState<Gradient | null>(
    value ?? null,
  );

  // stateRef mirrors the last committed `internal` value so setters can
  // compute and emit synchronously. `apply` also advances it immediately so
  // chained setters in one event handler each see the previous result.
  const stateRef = React.useRef(internal);

  // Latest-callback ref so a fresh arrow each render doesn't re-create setters.
  const onValueChangeRef = React.useRef(onValueChange);

  // Per-shape override stashes. The radial gradient can carry either an
  // ellipse override (`radii`) or a circle override (`radiusPx`), never
  // both at once — `setRadii` / `setRadiusPx` cross-clear. But emit logic
  // in `formatGradient` checks the override fields before the `shape`
  // flag, so a stale override left over from the previous shape would
  // silently keep drawing the old ending shape. We stash whichever
  // override belongs to the shape we're leaving, strip both off the
  // gradient, then restore the target shape's stash. Toggling back gives
  // the user their last numeric value instead of nothing.
  const radiiStashRef = React.useRef<{ x: number; y: number } | undefined>(
    undefined,
  );
  const radiusPxStashRef = React.useRef<number | undefined>(undefined);
  const [stashResetVersion, setStashResetVersion] = React.useState(0);

  React.useLayoutEffect(() => {
    stateRef.current = internal;
    idTrackedRef.current = idTracked;
    onValueChangeRef.current = onValueChange;
  }, [idTracked, internal, onValueChange]);

  React.useLayoutEffect(() => {
    if (stashResetVersion === 0) return;
    radiiStashRef.current = undefined;
    radiusPxStashRef.current = undefined;
  }, [stashResetVersion]);

  // Sync controlled value during render (the "adjusting state during render"
  // pattern from React docs) instead of an effect. An effect would commit a
  // render with stale `internal`, then schedule a second render — children
  // would see one frame with the previous gradient. Doing the reconcile here
  // means the same render that observes a new `value` also renders with the
  // reconciled internal state.
  const [prevControlledValue, setPrevControlledValue] = React.useState<
    Gradient | undefined
  >(value);
  if (isControlled && value !== prevControlledValue) {
    setPrevControlledValue(value);
    // An echo is consumed exactly once: a parent that later hands back the
    // same retained object (undo/redo) is describing a real change.
    if (lastEmitted !== null) setLastEmitted(null);
    if (value !== lastEmitted) {
      const prev = internal;
      // `prev.stops` is always position-sorted (attachIds and every mutating
      // setter sort), so the incoming array must be sorted the same way
      // before the element-wise compare. Without this, a controlled consumer
      // who keeps their stops in insertion order rather than position order
      // fails the match on *every* update and gets fresh ids each time —
      // which orphans `selectedStopId` and any per-stop color format.
      //
      // Without ids, stops are re-paired by index, so two sharing a position
      // are disambiguated only by array order: swap two coincident stops and
      // the ids stay put while the colors move between them. That is
      // long-standing behavior (unchanged by the sort) and is pinned by the
      // `stop identity with duplicate positions` tests. Consumers who need
      // exactness opt into `GradientStop.id` and take the identity path
      // below.
      const incoming = [...value.stops].sort(
        (a, b) => a.position - b.position,
      );
      // Identity path: when every incoming stop carries an id and that set is
      // exactly what we hold, pair on the id. This is the only way to follow
      // stops through a reorder that position+index cannot describe — two
      // stops sharing a position, most obviously.
      const prevIds = new Set(prev.stops.map((s) => s.id));
      const sameLength = prev.stops.length === incoming.length;
      // Ids, when supplied, are authoritative: a changed id set means the
      // consumer is describing different stops, even if the positions happen
      // to line up. Falling back to the position path there would ignore the
      // identity the consumer just handed us.
      const allHaveIds = incoming.length > 0 && incoming.every((s) => !!s.id);
      // Duplicated ids can't identify anything — treat them as un-tagged
      // rather than aliasing two stops onto one id.
      const uniqueIds =
        allHaveIds && new Set(incoming.map((s) => s.id)).size === incoming.length;
      const byId =
        uniqueIds && sameLength && incoming.every((s) => prevIds.has(s.id!));
      const sameShape = uniqueIds
        ? byId
        : sameLength &&
          prev.stops.every((s, i) => s.position === incoming[i].position);
      setIdTracked(uniqueIds);
      const structuralMatch = prev.gradient.type === value.type && sameShape;
      const next: InternalState = structuralMatch
        ? {
            gradient: value,
            stops: byId
              ? incoming.map((s) => ({ ...s, id: s.id as string }))
              : prev.stops.map((s, i) => ({ ...incoming[i], id: s.id })),
          }
        : attachIds(value);
      if (!structuralMatch) {
        setStashResetVersion((version) => version + 1);
        // Fresh ids orphan the selection; keep the same slot selected.
        if (!next.stops.some((s) => s.id === selectedStopId)) {
          const index = prev.stops.findIndex((s) => s.id === selectedStopId);
          const fallback =
            next.stops[Math.min(Math.max(index, 0), next.stops.length - 1)];
          if (fallback) setSelectedStopId(fallback.id);
        }
      }
      setInternal(next);
    }
  }

  // Apply a state transition + emit in one shot. Read-modify-write through
  // stateRef so chained calls in one event handler each see the previous
  // result. Returning `prev` (or null) is a no-op signal.
  const apply = React.useCallback(
    (compute: (prev: InternalState) => InternalState | null) => {
      const prev = stateRef.current;
      const next = compute(prev);
      if (next === null || next === prev) return;
      stateRef.current = next;
      setInternal(next);
      const clean = toPublicGradient(next, idTrackedRef.current);
      setLastEmitted(clean);
      onValueChangeRef.current?.(clean, formatGradient(clean));
    },
    [],
  );

  // ---- Gradient-level setters ----------------------------------------------

  const setGradient = React.useCallback(
    (next: Gradient) => {
      // Wholesale gradient replacement invalidates per-shape stashes — the
      // user is handing us a brand-new gradient, not toggling the current one.
      radiiStashRef.current = undefined;
      radiusPxStashRef.current = undefined;
      const tracksIds = hasStopIds(next);
      idTrackedRef.current = tracksIds;
      setIdTracked(tracksIds);
      apply(() => attachIds(next));
      setSelectedStopId((prev) => stateRef.current.stops[0]?.id ?? prev);
    },
    [apply],
  );

  const setType = React.useCallback(
    (type: GradientType) =>
      apply((prev) => {
        if (prev.gradient.type === type) return prev;
        radiiStashRef.current = undefined;
        radiusPxStashRef.current = undefined;
        const fallback = defaultsForType(type);
        return {
          gradient: { ...fallback, interp: prev.gradient.interp } as Gradient,
          stops: prev.stops,
        };
      }),
    [apply],
  );

  const setAngle = React.useCallback(
    (angleDeg: number) =>
      apply((prev) => {
        if (prev.gradient.type !== "linear") return prev;
        const cur = prev.gradient as LinearGradient;
        // Setting an angle directly clears any free-position endpoints — the
        // angle-only model can't represent both. Callers that want to
        // preserve positioned mode should `setLinearStart` / `setLinearEnd`
        // (which keep `angle` in sync with the derived direction).
        const { start: _s, end: _e, ...rest } = cur;
        const next: LinearGradient = { ...rest, angle: angleDeg };
        return { gradient: next, stops: prev.stops };
      }),
    [apply],
  );

  const setLinearStart = React.useCallback(
    (xy: { x: number; y: number } | undefined) =>
      apply((prev) => {
        if (prev.gradient.type !== "linear") return prev;
        const cur = prev.gradient as LinearGradient;
        const next: LinearGradient = xy
          ? {
              ...cur,
              start: { x: clamp01(xy.x), y: clamp01(xy.y) },
              angle: angleFromPoints(
                { x: clamp01(xy.x), y: clamp01(xy.y) },
                cur.end,
              ) ?? cur.angle,
            }
          : (() => {
              const { start: _drop, ...rest } = cur;
              return rest as LinearGradient;
            })();
        return { gradient: next, stops: prev.stops };
      }),
    [apply],
  );

  const setLinearEnd = React.useCallback(
    (xy: { x: number; y: number } | undefined) =>
      apply((prev) => {
        if (prev.gradient.type !== "linear") return prev;
        const cur = prev.gradient as LinearGradient;
        const next: LinearGradient = xy
          ? {
              ...cur,
              end: { x: clamp01(xy.x), y: clamp01(xy.y) },
              angle: angleFromPoints(
                cur.start,
                { x: clamp01(xy.x), y: clamp01(xy.y) },
              ) ?? cur.angle,
            }
          : (() => {
              const { end: _drop, ...rest } = cur;
              return rest as LinearGradient;
            })();
        return { gradient: next, stops: prev.stops };
      }),
    [apply],
  );

  const setStartAngle = React.useCallback(
    (angleDeg: number) =>
      apply((prev) => {
        if (prev.gradient.type !== "conic") return prev;
        return {
          gradient: { ...(prev.gradient as ConicGradient), startAngle: angleDeg },
          stops: prev.stops,
        };
      }),
    [apply],
  );

  const setCenter = React.useCallback(
    (xy: { x: number; y: number }) =>
      apply((prev) => {
        if (prev.gradient.type === "linear") return prev;
        return {
          gradient: {
            ...(prev.gradient as RadialGradient | ConicGradient),
            center: { x: clamp01(xy.x), y: clamp01(xy.y) },
          },
          stops: prev.stops,
        };
      }),
    [apply],
  );

  const setInterp = React.useCallback(
    (interp: GradientInterp) =>
      apply((prev) => ({
        gradient: { ...prev.gradient, interp } as Gradient,
        stops: prev.stops,
      })),
    [apply],
  );

  const setRepeating = React.useCallback(
    (repeating: boolean) =>
      apply((prev) => {
        const cur = prev.gradient;
        // Strip the flag entirely when toggled off so the gradient stays
        // structurally identical to one that never had it — keeps equality
        // checks and serialization minimal.
        if (!repeating) {
          if (!cur.repeating) return prev;
          const { repeating: _drop, ...rest } = cur;
          return { gradient: rest as Gradient, stops: prev.stops };
        }
        if (cur.repeating) return prev;
        return {
          gradient: { ...cur, repeating: true } as Gradient,
          stops: prev.stops,
        };
      }),
    [apply],
  );

  const setRadialShape = React.useCallback(
    (shape: "circle" | "ellipse") =>
      apply((prev) => {
        if (prev.gradient.type !== "radial") return prev;
        const cur = prev.gradient as RadialGradient;
        if (cur.shape === shape) return prev;
        if (cur.shape === "circle") {
          radiusPxStashRef.current = cur.radiusPx;
        } else {
          radiiStashRef.current = cur.radii;
        }
        const { radii: _r, radiusPx: _px, ...rest } = cur;
        const next: RadialGradient =
          shape === "circle"
            ? {
                ...rest,
                shape,
                ...(radiusPxStashRef.current !== undefined
                  ? { radiusPx: radiusPxStashRef.current }
                  : {}),
              }
            : {
                ...rest,
                shape,
                ...(radiiStashRef.current
                  ? { radii: radiiStashRef.current }
                  : {}),
              };
        return { gradient: next, stops: prev.stops };
      }),
    [apply],
  );

  const setRadialSize = React.useCallback(
    (size: RadialSizeKeyword) =>
      apply((prev) => {
        if (prev.gradient.type !== "radial") return prev;
        const base = prev.gradient as RadialGradient;
        // The keyword form and the explicit `radii` / `radiusPx` overrides
        // are mutually exclusive at emit time — `formatGradient` reads the
        // overrides first and never falls through to `shape size`. Picking
        // a size keyword is the user explicitly opting back into the
        // keyword path, so drop the numeric overrides (and the stash) in
        // the same commit. Without this, the dropdown changes the model
        // field but the rendered CSS doesn't move.
        radiiStashRef.current = undefined;
        radiusPxStashRef.current = undefined;
        const { radii: _r, radiusPx: _px, ...rest } = base;
        return {
          gradient: { ...rest, size } as RadialGradient,
          stops: prev.stops,
        };
      }),
    [apply],
  );

  const setRadii = React.useCallback(
    (radii: { x: number; y: number } | undefined) =>
      apply((prev) => {
        if (prev.gradient.type !== "radial") return prev;
        const base = prev.gradient as RadialGradient;
        // Setting `radii` is the ellipse path. Three jobs:
        //   1. Clear `radiusPx` so the two override fields never coexist.
        //   2. Enforce `shape: "ellipse"` — `formatGradient` emits
        //      `ellipse <rx> <ry>` regardless of `shape` when `radii` is
        //      present, so anything else would desync the model from CSS
        //      (and from the ShapeSwitcher / radius input UI).
        //   3. If the previous shape was circle, stash its `radiusPx` so the
        //      user can flip back via ShapeSwitcher without losing it (the
        //      stash matches `setRadialShape`'s own preservation behavior).
        if (base.shape === "circle" && base.radiusPx !== undefined) {
          radiusPxStashRef.current = base.radiusPx;
        }
        const { radii: _dropRadii, radiusPx: _dropPx, ...rest } = base;
        const next: RadialGradient = radii
          ? {
              ...rest,
              shape: "ellipse",
              radii: { x: Math.max(0, radii.x), y: Math.max(0, radii.y) },
            }
          : ({ ...rest, shape: "ellipse" } as RadialGradient);
        return { gradient: next, stops: prev.stops };
      }),
    [apply],
  );

  const [containerWidth, setContainerWidth] =
    React.useState<number | null>(null);

  const setRadiusPx = React.useCallback(
    (px: number | undefined) =>
      apply((prev) => {
        if (prev.gradient.type !== "radial") return prev;
        const base = prev.gradient as RadialGradient;
        // Setting `radiusPx` is the circle path. Mirror `setRadii`:
        // clear `radii`, enforce `shape: "circle"` (so the model can't
        // disagree with CSS / the radius input), and stash any previous
        // ellipse radii so ShapeSwitcher → ellipse restores them.
        if (base.shape === "ellipse" && base.radii) {
          radiiStashRef.current = base.radii;
        }
        const { radii: _dropRadii, radiusPx: _dropPx, ...rest } = base;
        const next: RadialGradient =
          px !== undefined
            ? { ...rest, shape: "circle", radiusPx: Math.max(0, px) }
            : ({ ...rest, shape: "circle" } as RadialGradient);
        return { gradient: next, stops: prev.stops };
      }),
    [apply],
  );

  // ---- Stop setters --------------------------------------------------------

  const selectStop = React.useCallback(
    (id: string) => setSelectedStopId(id),
    [setSelectedStopId],
  );

  const addStop = React.useCallback(
    (position: number, color?: OklchColor): string => {
      const id = nextId();
      const fallback: OklchColor = { l: 0.5, c: 0, h: 0, alpha: 1 };
      apply((prev) => ({
        gradient: prev.gradient,
        stops: sortByPosition([
          ...prev.stops,
          { id, position, color: color ?? fallback },
        ]),
      }));
      setSelectedStopId(id);
      return id;
    },
    [apply, setSelectedStopId],
  );

  const removeStop = React.useCallback(
    (id: string) => {
      let nextSelId: string | null = null;
      apply((prev) => {
        if (prev.stops.length <= 1) return prev;
        const idx = prev.stops.findIndex((s) => s.id === id);
        if (idx === -1) return prev;
        const filtered = prev.stops.filter((s) => s.id !== id);
        nextSelId = filtered[Math.min(idx, filtered.length - 1)].id;
        return { gradient: prev.gradient, stops: filtered };
      });
      if (nextSelId !== null) setSelectedStopId(nextSelId);
    },
    [apply, setSelectedStopId],
  );

  const moveStop = React.useCallback(
    (id: string, position: number) => {
      // Intentionally unclamped: on a positioned linear gradient, authored
      // positions outside 0..1 extrapolate beyond the [start, end] segment
      // (legal CSS). UI inputs clamp their own *displayed* coordinates.
      apply((prev) => {
        // Move the target stop to the end before sorting so that in a position
        // tie it sorts after existing stops at that position (stable sort).
        const others = prev.stops.filter((s) => s.id !== id);
        const moved = prev.stops.find((s) => s.id === id);
        if (!moved) return prev;
        return {
          gradient: prev.gradient,
          stops: sortByPosition([...others, { ...moved, position }]),
        };
      });
    },
    [apply],
  );

  const setStopColor = React.useCallback(
    (id: string, color: OklchColor) =>
      apply((prev) => ({
        gradient: prev.gradient,
        stops: prev.stops.map((s) => (s.id === id ? { ...s, color } : s)),
      })),
    [apply],
  );

  const setStopHint = React.useCallback(
    (id: string, hint: number | undefined) =>
      apply((prev) => ({
        gradient: prev.gradient,
        stops: prev.stops.map((s) => (s.id === id ? { ...s, hint } : s)),
      })),
    [apply],
  );

  const reverseStops = React.useCallback(
    () =>
      apply((prev) => {
        if (prev.stops.length < 2) return prev;
        // Mirror each stop's position around 0.5 so the visual order flips
        // while ids stay attached to their colors. A hint lives on the stop
        // that *follows* its segment, so after the flip the mirrored hint
        // must move to the segment's other stop (the one that follows in
        // the new order) — keeping it in place would strand it on a stop
        // that now leads, where formatStops silently drops it.
        const movedHints = new Map<string, number>();
        prev.stops.forEach((s, i) => {
          if (s.hint !== undefined && i > 0) {
            movedHints.set(prev.stops[i - 1].id, 1 - s.hint);
          }
        });
        const flipped = prev.stops.map((s) => ({
          ...s,
          position: 1 - s.position,
          hint: movedHints.get(s.id),
        }));
        return { gradient: prev.gradient, stops: sortByPosition(flipped) };
      }),
    [apply],
  );

  // ---- Derived values ------------------------------------------------------

  const selectedStop = React.useMemo(
    () => internal.stops.find((s) => s.id === selectedStopId) ?? null,
    [internal.stops, selectedStopId],
  );

  const cleanGradient = React.useMemo(
    () => toPublicGradient(internal, idTracked),
    [idTracked, internal],
  );

  return {
    gradient: cleanGradient,
    stops: internal.stops,
    selectedStopId,
    selectedStop,
    setGradient,
    setType,
    setAngle,
    setLinearStart,
    setLinearEnd,
    setStartAngle,
    setCenter,
    setInterp,
    setRepeating,
    setRadialShape,
    setRadialSize,
    setRadii,
    setRadiusPx,
    containerWidth,
    setContainerWidth,
    selectStop,
    addStop,
    removeStop,
    moveStop,
    setStopColor,
    setStopHint,
    reverseStops,
    getStopColorFormat,
    setStopColorFormat,
  };
}
registry/new-york/ui/fill-picker/parts/gradient/root.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import {
  GradientPickerContext,
  GradientStopEditorContext,
  useGradientStopEditor,
  type GradientStopEditorRenderer,
} from "../../contexts/gradient";
import {
  useGradientPicker,
  type UseGradientPickerProps,
} from "../../hooks/use-gradient-picker";

export interface RootProps
  extends UseGradientPickerProps,
    Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
  /**
   * Editor mounted around each `<Bar editOnClick>` stop handle. Each barrel
   * (`GradientPicker` / `GradientPickerBase`) injects its own variant's editor
   * as the default; pass this to substitute your own. Omitted, the editor is
   * inherited from an enclosing provider — and with none (a deep-imported
   * engine `Root`), the Bar renders bare handles.
   */
  stopEditor?: GradientStopEditorRenderer;
}

export const Root = React.forwardRef<HTMLDivElement, RootProps>(function Root(
  {
    value,
    defaultValue,
    onValueChange,
    defaultStopColorFormat,
    stopEditor,
    className,
    children,
    ...rest
  },
  ref,
) {
  const state = useGradientPicker({
    value,
    defaultValue,
    onValueChange,
    defaultStopColorFormat,
  });
  // Omitting the prop inherits whatever editor an outer provider (a barrel's
  // `FillPicker.Pane`, say) already injected, rather than blanking it — a
  // deep-imported engine Root nested in a wired-up tree keeps its editor.
  const inherited = useGradientStopEditor();
  const editor = stopEditor ?? inherited;
  return (
    <GradientStopEditorContext.Provider value={editor}>
      <GradientPickerContext.Provider value={state}>
        <div
          ref={ref}
          data-slot="gradient-picker"
          className={cn(
            "flex w-full max-w-[280px] flex-col gap-3 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-sm",
            className,
          )}
          {...rest}
        >
          {children}
        </div>
      </GradientPickerContext.Provider>
    </GradientStopEditorContext.Provider>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/bar.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import {
  useGradientPickerContext,
  useGradientStopEditor,
} from "../../contexts/gradient";
import {
  adjustStopsForEndpoints,
  formatGradient,
  projectStopPosition,
  reverseProjectStopPosition,
  sampleStopsAt,
  type LinearGradient,
} from "../../lib/gradient";
import { formatColor } from "../../lib/color";
import { focusNeighborIn } from "./stop-list-shared";
import { useLiveAnnounce } from "../use-live-announce";

export interface BarProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Track height in px. Defaults to 12 to match `<ColorPicker.Hue>`. */
  height?: number;
  /** Handle (stop) diameter in px. Defaults to 16 to match the Hue thumb. */
  handleSize?: number;
  /**
   * Open the stop-color editor popover when a handle is clicked without
   * being dragged. Drag is still the canonical way to reposition a stop —
   * the click-vs-drag distinction is movement-based, so flicking the
   * handle even a pixel keeps the popover closed. Defaults to `false`.
   */
  editOnClick?: boolean;
}

function buildPreviewGradient(
  g: ReturnType<typeof useGradientPickerContext>["gradient"],
): string {
  // For the bar preview we always render the stops as a horizontal linear
  // gradient regardless of the live gradient type — the bar is the editing
  // surface for stop positions. When the live gradient is a positioned
  // linear with `start`/`end`, mirror the same stop projection used at CSS
  // emit time so the Bar shows the *visible* stop positions.
  const stops =
    g.type === "linear" && g.start && g.end
      ? adjustStopsForEndpoints(g.stops, g.start, g.end)
      : g.stops;
  const linear: LinearGradient = {
    type: "linear",
    angle: 90,
    interp: g.interp,
    stops,
  };
  return formatGradient(linear);
}

export const Bar = React.forwardRef<HTMLDivElement, BarProps>(function Bar(
  { className, height = 12, handleSize = 16, editOnClick = false, ...rest },
  ref,
) {
  // Which stop's editor popover is open (null = none). Only meaningful
  // when `editOnClick` is true.
  const [openStopId, setOpenStopId] = React.useState<string | null>(null);
  const ctx = useGradientPickerContext();
  const stopEditor = useGradientStopEditor();
  const wrapperRef = React.useRef<HTMLDivElement | null>(null);
  const [liveText, announce] = useLiveAnnounce();
  const trackRef = React.useRef<HTMLDivElement | null>(null);
  // Cleanup hook for an active drag's document-level listeners. Lets us tear
  // them down on unmount so a mid-drag remove of the Bar doesn't leave them
  // attached to `document` and firing `ctx.moveStop` on a stale context.
  const activeDragCleanupRef = React.useRef<(() => void) | null>(null);
  React.useEffect(
    () => () => {
      activeDragCleanupRef.current?.();
      activeDragCleanupRef.current = null;
    },
    [],
  );
  // Forward the consumer's ref to the outer wrapper (the element that carries
  // `data-slot="gradient-bar"`) so `wrapper.querySelector('[data-slot="gradient-bar"]')`
  // works as expected; pointer math still uses the inner trackRef.
  React.useImperativeHandle(ref, () => wrapperRef.current as HTMLDivElement);

  // When the live gradient is positioned-linear, stop positions are projected
  // onto the CSS gradient line for display + pointer math. Authored values
  // (what's written to `stop.position`) stay 0..1 along the segment.
  const linear = ctx.gradient.type === "linear" ? ctx.gradient : null;
  const start = linear?.start;
  const end = linear?.end;
  const toDisplay = (authored: number) =>
    projectStopPosition(authored, start, end);
  const fromDisplay = (displayed: number) =>
    reverseProjectStopPosition(displayed, start, end);

  const displayedPositionFromEvent = React.useCallback(
    (clientX: number): number => {
      const el = trackRef.current;
      if (!el) return 0;
      const rect = el.getBoundingClientRect();
      if (rect.width <= 0) return 0;
      return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
    },
    [],
  );

  // A stop added by clicking the track becomes the selected stop, but the
  // click landed on the track — which isn't focusable — so focus stays on
  // <body> and the new stop can't be nudged or deleted from the keyboard.
  // Its handle doesn't exist until the next render, hence the deferral.
  const pendingFocusIdRef = React.useRef<string | null>(null);
  React.useLayoutEffect(() => {
    const id = pendingFocusIdRef.current;
    if (!id) return;
    pendingFocusIdRef.current = null;
    // Matched by walking the handles rather than an attribute selector:
    // stop ids can come from the consumer now, so they aren't guaranteed
    // to be selector-safe.
    const handles = wrapperRef.current?.querySelectorAll<HTMLElement>(
      '[role="slider"]',
    );
    handles?.forEach((h) => {
      if (h.dataset.stopId === id) h.focus();
    });
  });

  const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (e.target !== trackRef.current) return; // handles handle their own drag
    // Canceling pointerdown suppresses the compatibility mousedown, and with
    // it the browser's default focus handling. Without this the layout effect
    // below focuses the new handle and the browser immediately takes it back
    // — clicking a non-focusable element blurs to <body> — so the stop you
    // just placed still wouldn't take Delete or the arrow keys.
    e.preventDefault();
    const displayed = displayedPositionFromEvent(e.clientX);
    // No clamp on the authored value: on a positioned linear, clicks outside
    // the projected [start, end] segment extrapolate (< 0 / > 1 are legal
    // CSS stop positions), so the stop lands where the user clicked instead
    // of stacking on the first/last stop. Plain mode is unaffected —
    // fromDisplay is the identity and `displayed` is already 0..1.
    const authored = fromDisplay(displayed);
    pendingFocusIdRef.current = ctx.addStop(
      authored,
      sampleStopsAt(ctx.stops, authored),
    );
  };

  const startStopDrag = (id: string) => (e: React.PointerEvent<HTMLDivElement>) => {
    e.stopPropagation();
    // preventDefault keeps the browser from starting a text selection or a
    // native drag mid-gesture, but it also suppresses the focus a pointerdown
    // would normally give the handle. Focus it explicitly: without this a
    // clicked stop looks selected yet keyboard keys (nudge, Delete) go to
    // <body> and do nothing.
    e.preventDefault();
    e.currentTarget.focus();
    ctx.selectStop(id);

    // Listen on document, not the handle DOM node. moveStop reorders the
    // stops array, and React reconciles the per-stop <div>s in their new
    // order; if we captured/listened on the original handle element, a
    // sibling handle could end up under the pointer and steal the gesture.
    // Document-level listeners + an `id` captured in closure keep the drag
    // bound to the originally-clicked stop regardless of reordering.
    let pendingRemove = false;
    // Track pointer movement so pointerUp can tell a tap from a drag —
    // 3px slack absorbs the jitter of a deliberate click.
    const startX = e.clientX;
    const startY = e.clientY;
    // Document-level listeners see every pointer; only the one that started
    // this drag may steer or end it (multi-touch).
    const dragPointerId = e.pointerId;
    let moved = false;
    const onMove = (ev: PointerEvent) => {
      if (ev.pointerId !== dragPointerId) return;
      // Self-heal a missed release: without pointer capture, letting go of
      // the button outside the browser window never delivers pointerup, so
      // the first re-entry move arrives with no buttons pressed. End the
      // drag instead of letting the stop chase the cursor.
      if (ev.buttons === 0) {
        cleanup();
        if (pendingRemove) ctx.removeStop(id);
        return;
      }
      if (!moved) {
        const dx = ev.clientX - startX;
        const dy = ev.clientY - startY;
        if (dx * dx + dy * dy > 9) moved = true;
      }
      const rect = trackRef.current?.getBoundingClientRect();
      if (!rect) return;
      const dy = ev.clientY - rect.bottom;
      // Mark for removal while dragged below the bar by more than 24px,
      // but only commit on release so the user can drag back up to cancel.
      pendingRemove = dy > 24 && ctx.stops.length > 1;
      if (!pendingRemove && moved) {
        const displayed = displayedPositionFromEvent(ev.clientX);
        ctx.moveStop(id, fromDisplay(displayed));
      }
    };
    const cleanup = () => {
      document.removeEventListener("pointermove", onMove);
      document.removeEventListener("pointerup", onUp);
      document.removeEventListener("pointercancel", onUp);
      activeDragCleanupRef.current = null;
    };
    const onUp = (ev: PointerEvent) => {
      if (ev.pointerId !== dragPointerId) return;
      cleanup();
      if (pendingRemove) ctx.removeStop(id);
      else if (!moved && editOnClick) setOpenStopId(id);
    };
    document.addEventListener("pointermove", onMove);
    document.addEventListener("pointerup", onUp);
    document.addEventListener("pointercancel", onUp);
    activeDragCleanupRef.current = cleanup;
  };

  const onStopKeyDown =
    (id: string, position: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {
      const step = e.shiftKey ? 0.05 : 0.01;
      // Nudge in *displayed* space so a 1% press visibly moves the stop the
      // same distance on screen regardless of whether the gradient is
      // positioned-linear or angle-only.
      const displayed = toDisplay(position);
      // Clamp in displayed space: keyboard nudges stay on the visible track
      // (moveStop itself no longer clamps, to allow extrapolated stops).
      const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
      if (e.key === "ArrowLeft") {
        e.preventDefault();
        ctx.moveStop(id, fromDisplay(clamp01(displayed - step)));
      } else if (e.key === "ArrowRight") {
        e.preventDefault();
        ctx.moveStop(id, fromDisplay(clamp01(displayed + step)));
      } else if (e.key === "Delete" || e.key === "Backspace") {
        e.preventDefault();
        // A gradient needs at least two stops; `removeStop` refuses below
        // that. The StopList's mouse affordance conveys this by rendering
        // its remove button `disabled` — the Bar handle has no such visual,
        // so announce instead of failing silently.
        if (ctx.stops.length <= 1) {
          announce("Cannot remove the last stop");
          return;
        }
        // Move focus to a neighboring handle *before* removal, mirroring
        // StopList: the focused node is about to unmount, and without this
        // focus falls to <body> and keyboard navigation is lost.
        focusNeighborIn(wrapperRef.current, e.currentTarget, '[role="slider"]');
        ctx.removeStop(id);
        announce("Stop removed");
      }
    };

  return (
    <div
      ref={wrapperRef}
      data-slot="gradient-bar"
      className={cn("relative w-full select-none", className)}
      style={{ height }}
      {...rest}
    >
      <div
        ref={trackRef}
        onPointerDown={onTrackPointerDown}
        className="absolute inset-0 touch-none rounded-full border border-border"
        style={{
          background: `${buildPreviewGradient(ctx.gradient)}, repeating-conic-gradient(#bbb 0 25%, #fff 0 50%) 0 0/8px 8px`,
        }}
      />
      {ctx.stops.map((s) => {
        const selected = s.id === ctx.selectedStopId;
        const displayed = Math.max(0, Math.min(1, toDisplay(s.position)));
        const displayedPct = Math.round(displayed * 100);
        const handle = (
          <div
            role="slider"
            data-stop-id={s.id}
            aria-label={`Stop at ${displayedPct}%`}
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={displayedPct}
            aria-valuetext={`${displayedPct} percent`}
            aria-orientation="horizontal"
            aria-current={selected ? "true" : undefined}
            tabIndex={0}
            onPointerDown={startStopDrag(s.id)}
            onFocus={() => ctx.selectStop(s.id)}
            onKeyDown={onStopKeyDown(s.id, s.position)}
            style={{
              // Match the Hue thumb's positioning math: inset the centerline
              // by handleSize/2 so pos=0/1 sit flush with the track edges.
              left: `calc(${displayed} * (100% - ${handleSize}px) + ${handleSize / 2}px)`,
              width: handleSize,
              height: handleSize,
              background: formatColor(s.color, "oklch"),
            }}
            className={cn(
              "absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.6)]",
              // WCAG 2.5.8: small visual dot, ≥24px pointer target.
              "before:absolute before:-inset-1.5 before:content-['']",
              selected && "outline-2 outline-offset-1 outline-ring",
            )}
          />
        );
        // The editor is a slot, not an import: the Bar is shared by both
        // variants, so the dialect-specific editor is injected by the barrel
        // (see `GradientStopEditorRenderer`). Without one — or without
        // `editOnClick` — the handle renders bare.
        if (!editOnClick || !stopEditor)
          return <React.Fragment key={s.id}>{handle}</React.Fragment>;
        return (
          <React.Fragment key={s.id}>
            {stopEditor({
              stopId: s.id,
              open: openStopId === s.id,
              onOpenChange: (o) => setOpenStopId(o ? s.id : null),
              children: handle,
            })}
          </React.Fragment>
        );
      })}
      <span aria-live="polite" className="sr-only">
        {liveText}
      </span>
    </div>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/overlay.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { type RadialSizeKeyword } from "../../lib/gradient";
import { formatColor } from "../../lib/color";
import { CHECKERBOARD_LG } from "../../lib/constants";
import { useLiveAnnounce } from "../use-live-announce";
import { trackPointerDrag } from "./pointer-drag";

export interface OverlayProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * Pixel radius of the conic "from-angle" dial handle measured from the
   * center handle. Locked on this ring; drag rotates `startAngle`.
   */
  conicDialRadius?: number;
}

interface XY {
  x: number;
  y: number;
}

const HANDLE_PX = 14;

// Re-exported so `<GradientPicker.Area>` can paint its own checkerboard
/**
 * Convert a CSS gradient angle (0deg = up, increases clockwise) to a unit
 * direction vector in screen coordinates (y goes down).
 *   0deg   → ( 0, -1)   up
 *   90deg  → ( 1,  0)   right
 *   180deg → ( 0,  1)   down
 *   270deg → (-1,  0)   left
 */
function angleToDir(angleDeg: number): XY {
  const r = (angleDeg * Math.PI) / 180;
  return { x: Math.sin(r), y: -Math.cos(r) };
}

/**
 * Inverse of `angleToDir`. Returns a CSS gradient angle in [0, 360) given a
 * non-zero direction vector in screen coordinates.
 */
function dirToAngle(dx: number, dy: number): number {
  // atan2(dx, -dy): 0 = up, increases clockwise — matches AnglePad.
  const deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
  return (deg + 360) % 360;
}

/**
 * Project a ray from the box center along `dir` and return the distance to
 * the box edge. Works for any aspect ratio. Caller must ensure |dir| > 0.
 */
function edgeExtent(dir: XY, halfW: number, halfH: number): number {
  const ex = dir.x === 0 ? Infinity : halfW / Math.abs(dir.x);
  const ey = dir.y === 0 ? Infinity : halfH / Math.abs(dir.y);
  return Math.min(ex, ey);
}

/** Snap an angle to the nearest `step` degrees, normalized to [0, 360). */
function snapDeg(deg: number, step: number): number {
  const s = Math.round(deg / step) * step;
  return ((s % 360) + 360) % 360;
}

/**
 * Seed numeric radii from the radial gradient's keyword form (`shape` +
 * `size`) so the edge handle has a sensible starting position before the
 * user has touched it. Returns radii as fractions of the box width/height
 * matching the CSS `<length-percentage>{1,2}` convention.
 *
 * For corner-targeting keywords on ellipse we deliberately approximate by
 * stretching each axis to the appropriate side rather than implementing the
 * exact CSS spec — once the user drags, they own the radii anyway.
 */
export function keywordToRadii(
  shape: "circle" | "ellipse",
  size: RadialSizeKeyword,
  center: { x: number; y: number },
  w: number,
  h: number,
): { x: number; y: number } {
  const cx = center.x * w;
  const cy = center.y * h;
  const dxClose = Math.min(cx, w - cx);
  const dxFar = Math.max(cx, w - cx);
  const dyClose = Math.min(cy, h - cy);
  const dyFar = Math.max(cy, h - cy);
  if (shape === "circle") {
    let rPx: number;
    switch (size) {
      case "closest-side":
        rPx = Math.min(dxClose, dyClose);
        break;
      case "closest-corner":
        rPx = Math.hypot(dxClose, dyClose);
        break;
      case "farthest-side":
        rPx = Math.max(dxFar, dyFar);
        break;
      case "farthest-corner":
        rPx = Math.hypot(dxFar, dyFar);
        break;
    }
    return { x: rPx / w, y: rPx / h };
  }
  switch (size) {
    case "closest-side":
      return { x: dxClose / w, y: dyClose / h };
    case "closest-corner":
      // Ellipse closest-corner: stretch each axis to the *closest* side along
      // that axis (approximation — the spec scales to actually touch the
      // closest corner, but visually this is close enough as a seed).
      return { x: dxClose / w, y: dyClose / h };
    case "farthest-side":
      return { x: dxFar / w, y: dyFar / h };
    case "farthest-corner":
      return { x: dxFar / w, y: dyFar / h };
  }
}

/**
 * Transparent handle-only overlay that surfaces the *geometry* of the
 * active gradient on top of any element. Designed to be dropped over a
 * consumer's own object (a canvas frame, a div on a design surface,
 * etc.) so the user can edit the gradient in place — same interactions
 * as `<GradientPicker.Area>` but without painting the gradient itself.
 * The host container must establish a positioning context (e.g.
 * `position: relative`) and represent the same coordinate space the
 * gradient will be applied to.
 *
 * Handle behavior by gradient type (identical to the in-Area version):
 *
 * - **Linear** — start / end endpoint handles tinted with the first / last
 *   stop colors. Dragging endpoints rotates the angle (and, on first
 *   drag, promotes to free-position mode). Middle stops are edited on
 *   `<GradientPicker.Bar>`, never on the canvas — the overlay only edits
 *   *geometry*. Consumers mounting an Overlay by itself should pair it
 *   with a `<GradientPicker.Bar>` or `<GradientPicker.StopList>` somewhere
 *   in the same Root, or inner stops won't be editable by pointer at all.
 * - **Radial** — center handle + edge handle. Edge handle drives radius
 *   (px for circles, normalized for ellipses); center moves the
 *   gradient and leaves the keyword `size` alone — keyword extents
 *   (`farthest-corner` etc.) naturally recompute against the new center.
 * - **Conic** — center handle + dial handle for `startAngle`.
 *
 * The root div is `pointer-events-none` so empty regions pass clicks
 * through to whatever sits beneath; the handle buttons themselves
 * intercept events normally. Pair with `<GradientPicker.Area>` for a
 * self-contained preview, or with any custom container for in-place
 * editing on the consumer's canvas.
 */
export const Overlay = React.forwardRef<HTMLDivElement, OverlayProps>(
  function Overlay({ className, conicDialRadius, style, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  const { gradient } = ctx;
  const padRef = React.useRef<HTMLDivElement | null>(null);
  React.useImperativeHandle(ref, () => padRef.current as HTMLDivElement);

  // Observe the host's dimensions (which the overlay fills via `inset-0`)
  // so handle geometry stays correct at any aspect ratio.
  const [dims, setDims] = React.useState<{ w: number; h: number }>({
    w: 0,
    h: 0,
  });
  const setContainerWidth = ctx.setContainerWidth;
  React.useEffect(() => {
    const el = padRef.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => {
      const entry = entries[0];
      if (!entry) return;
      const { width, height: h } = entry.contentRect;
      setDims({ w: width, h });
      // Publish width to context so sibling parts (e.g. the radius input
      // on `<GradientPicker.RadiusInput>`) can convert between absolute
      // px and a percentage display.
      setContainerWidth(width);
    });
    ro.observe(el);
    // Intentionally do NOT clear `containerWidth` on unmount: if multiple
    // Overlays / an <Area> + an Overlay are mounted simultaneously (e.g.
    // the playground's "Area + Overlay (demo)" toggle), the surviving
    // publisher should keep ownership of the value. The next mount will
    // overwrite it; until then a stale-but-non-null last-known width is
    // strictly better than null (which would force percentage inputs to
    // fall back to px and break the user's display unit mid-session).
    return () => ro.disconnect();
  }, [setContainerWidth]);

  // Compute handle positions in *box-local* pixels. Center of the box is
  // (w/2, h/2). All handle math lives in this coordinate space so we can
  // reason about it without thinking about page offsets.
  const handles = React.useMemo(() => {
    const { w, h } = dims;
    if (w === 0 || h === 0) return null;
    const cx = w / 2;
    const cy = h / 2;

    if (gradient.type === "linear") {
      if (gradient.start && gradient.end) {
        // Positioned linear: endpoints can sit anywhere inside the box.
        // Visually clamp by HANDLE_PX/2 so the handle dot stays fully inside
        // the `overflow-hidden` container even when the user drags to a
        // corner. The stored `gradient.start` / `gradient.end` keep the
        // true pointer position — only the rendered handle + dashed line
        // endpoint are inset.
        const inset = HANDLE_PX / 2;
        const clampX = (px: number) => Math.max(inset, Math.min(w - inset, px));
        const clampY = (px: number) => Math.max(inset, Math.min(h - inset, px));
        return {
          a: {
            x: clampX(gradient.start.x * w),
            y: clampY(gradient.start.y * h),
          },
          b: {
            x: clampX(gradient.end.x * w),
            y: clampY(gradient.end.y * h),
          },
          showConnector: true,
        };
      }
      const dir = angleToDir(gradient.angle);
      // Inset the edge by half a handle so the dot stays fully inside the box
      // and doesn't get clipped by `overflow-hidden` on the container. The
      // gradient itself still paints to the true edge — only the handle
      // visualization is inset.
      const inset = HANDLE_PX / 2;
      const t = edgeExtent(
        dir,
        Math.max(0, w / 2 - inset),
        Math.max(0, h / 2 - inset),
      );
      return {
        a: { x: cx - dir.x * t, y: cy - dir.y * t },
        b: { x: cx + dir.x * t, y: cy + dir.y * t },
        showConnector: true,
      };
    }

    if (gradient.type === "radial") {
      const ax = gradient.center.x * w;
      const ay = gradient.center.y * h;
      // Edge handle position resolves in three precedence tiers, matching
      // formatGradient's emit logic:
      //   1. circle + radiusPx → place the handle on the ring at (rPx, 0).
      //      Single-axis since the gradient is pixel-circular.
      //   2. radii (ellipse) → place the handle at the +x +y corner of the
      //      ellipse bounding box.
      //   3. keyword form → seed both from `keywordToRadii` and treat the
      //      handle as ellipse-style for the visual until the user
      //      promotes (drag promotes per shape).
      let bx: number;
      let by: number;
      if (gradient.shape === "circle" && gradient.radiusPx !== undefined) {
        bx = ax + gradient.radiusPx;
        by = ay;
      } else if (gradient.radii) {
        bx = ax + gradient.radii.x * w;
        by = ay + gradient.radii.y * h;
      } else {
        const seeded = keywordToRadii(
          gradient.shape,
          gradient.size,
          gradient.center,
          w,
          h,
        );
        bx = ax + seeded.x * w;
        by = ay + seeded.y * h;
      }
      return {
        a: { x: ax, y: ay },
        b: { x: bx, y: by } as XY,
        showConnector: false,
      };
    }

    // conic
    const a = { x: gradient.center.x * w, y: gradient.center.y * h };
    const dir = angleToDir(gradient.startAngle);
    const dialR = conicDialRadius ?? Math.min(w, h) / 2 - HANDLE_PX;
    return {
      a,
      b: { x: a.x + dir.x * dialR, y: a.y + dir.y * dialR },
      showConnector: true,
    };
  }, [dims, gradient, conicDialRadius]);

  // Drag helpers ------------------------------------------------------------

  const localFromEvent = (clientX: number, clientY: number): XY => {
    const el = padRef.current;
    if (!el) return { x: 0, y: 0 };
    const r = el.getBoundingClientRect();
    return { x: clientX - r.left, y: clientY - r.top };
  };

  type HandleKind =
    | "linear-a"
    | "linear-b"
    | "center"
    | "conic-dial"
    | "radial-edge";

  const handleAt = (kind: HandleKind, p: XY, shiftKey: boolean) => {
    const { w, h } = dims;
    const cx = w / 2;
    const cy = h / 2;

    if (kind === "linear-a" || kind === "linear-b") {
      if (gradient.type !== "linear") return;
      // Free position: drag puts the endpoint exactly where the pointer is.
      // Promote the gradient to "positioned" mode by ensuring both `start`
      // and `end` are populated — seed the other endpoint from the angle if
      // this is the first drag.
      const nx = Math.max(0, Math.min(1, p.x / w));
      const ny = Math.max(0, Math.min(1, p.y / h));
      const ensureOther = (existing: { x: number; y: number } | undefined) => {
        if (existing) return existing;
        // Seed from the current angle so the line keeps its visual direction
        // for the very first drag — no visible jump on promotion.
        const dir = angleToDir(gradient.angle);
        const inset = HANDLE_PX / 2;
        const t = edgeExtent(
          dir,
          Math.max(0, w / 2 - inset),
          Math.max(0, h / 2 - inset),
        );
        // The "other" endpoint is opposite the one being dragged.
        const sign = kind === "linear-a" ? 1 : -1;
        const ox = cx + dir.x * t * sign;
        const oy = cy + dir.y * t * sign;
        return { x: ox / w, y: oy / h };
      };
      if (kind === "linear-a") {
        const other = ensureOther(gradient.end);
        if (!gradient.end) ctx.setLinearEnd(other);
        ctx.setLinearStart({ x: nx, y: ny });
      } else {
        const other = ensureOther(gradient.start);
        if (!gradient.start) ctx.setLinearStart(other);
        ctx.setLinearEnd({ x: nx, y: ny });
      }
      return;
    }

    if (kind === "center") {
      const nx = Math.max(0, Math.min(1, p.x / w));
      const ny = Math.max(0, Math.min(1, p.y / h));
      ctx.setCenter({ x: nx, y: ny });
      return;
    }

    if (kind === "radial-edge") {
      if (gradient.type !== "radial") return;
      const ax = gradient.center.x * w;
      const ay = gradient.center.y * h;
      // Edge handle lives in the +x +y quadrant relative to the center —
      // drags into other quadrants are mirrored via abs() so radii stay
      // strictly non-negative (CSS forbids negative gradient radii).
      const dxPx = Math.abs(p.x - ax);
      const dyPx = Math.abs(p.y - ay);
      // Honor the declared shape, Shift inverts (and flips shape in the
      // model — `setRadiusPx` / `setRadii` enforce `shape: "circle"` /
      // `"ellipse"` respectively, so the data, the CSS output, and the
      // ShapeSwitcher all stay in sync after a Shift-drag).
      //   shape=circle + no shift  → stays circle, writes radiusPx
      //   shape=circle + shift     → flips to ellipse, writes radii
      //   shape=ellipse + no shift → stays ellipse, writes radii
      //   shape=ellipse + shift    → flips to circle, writes radiusPx
      const renderAsCircle = (gradient.shape === "circle") !== shiftKey;
      if (renderAsCircle) {
        // True pixel circle: pick the larger pointer distance as the
        // radius. Stored in absolute pixels so the consumer-side CSS
        // (`circle <px>px`) renders a real circle in any container.
        const rPx = Math.max(dxPx, dyPx);
        ctx.setRadiusPx(rPx);
      } else {
        ctx.setRadii({
          x: Math.max(0, Math.min(2, dxPx / w)),
          y: Math.max(0, Math.min(2, dyPx / h)),
        });
      }
      return;
    }

    // conic-dial — rotate around current center, distance is ignored.
    if (gradient.type !== "conic") return;
    const ax = gradient.center.x * w;
    const ay = gradient.center.y * h;
    let deg = dirToAngle(p.x - ax, p.y - ay);
    if (shiftKey) deg = snapDeg(deg, 15);
    ctx.setStartAngle(deg);
  };

  // Keyboard nudge helpers --------------------------------------------------

  const rotate = (current: number, e: React.KeyboardEvent): number | null => {
    const step = e.shiftKey ? 15 : 1;
    let next = current;
    if (e.key === "ArrowRight" || e.key === "ArrowUp") next = current + step;
    else if (e.key === "ArrowLeft" || e.key === "ArrowDown")
      next = current - step;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = 359;
    else return null;
    return ((next % 360) + 360) % 360;
  };

  // 2D handles (center, free-positioned endpoints) use role="application",
  // which has no value semantics — announce keyboard moves in a polite live
  // region rendered at the overlay root.
  const [liveText, announce] = useLiveAnnounce();

  /**
   * Stack three background layers so a handle dot shows the stop's actual
   * color (alpha intact) while still fully occluding the dashed gradient
   * line behind it: stop-color on top, checkerboard underneath so
   * transparency reads as transparency, and the Handle's own solid
   * `bg-background` at the bottom for the final opacity guarantee.
   */
  const stopSwatchStyle = (color: typeof ctx.stops[number]["color"]) => {
    const css = formatColor(color, "oklch");
    return {
      backgroundImage: `linear-gradient(${css}, ${css}), ${CHECKERBOARD_LG}`,
      backgroundSize: "auto, 6px 6px",
    } as const;
  };

  const onKeyDownAngle = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (gradient.type !== "linear") return;
    const next = rotate(gradient.angle, e);
    if (next === null) return;
    e.preventDefault();
    ctx.setAngle(next);
  };

  const onKeyDownLinearEndpoint = (
    which: "linear-a" | "linear-b",
    e: React.KeyboardEvent<HTMLButtonElement>,
  ) => {
    if (gradient.type !== "linear") return;
    const point =
      which === "linear-a" ? gradient.start : gradient.end;
    if (!point) {
      // Fall back to angle rotation when this handle hasn't been promoted
      // to free-position mode yet — keeps the angle-only behavior intact.
      onKeyDownAngle(e);
      return;
    }
    const step = e.shiftKey ? 0.05 : 0.01;
    let { x, y } = point;
    if (e.key === "ArrowLeft") x -= step;
    else if (e.key === "ArrowRight") x += step;
    else if (e.key === "ArrowUp") y -= step;
    else if (e.key === "ArrowDown") y += step;
    else return;
    e.preventDefault();
    const next = {
      x: Math.max(0, Math.min(1, x)),
      y: Math.max(0, Math.min(1, y)),
    };
    if (which === "linear-a") ctx.setLinearStart(next);
    else ctx.setLinearEnd(next);
    announce(
      `${which === "linear-a" ? "Start" : "End"} x ${Math.round(next.x * 100)}%, y ${Math.round(next.y * 100)}%`,
    );
  };

  const onKeyDownConicDial = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (gradient.type !== "conic") return;
    const next = rotate(gradient.startAngle, e);
    if (next === null) return;
    e.preventDefault();
    ctx.setStartAngle(next);
  };

  const onKeyDownRadii = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (gradient.type !== "radial") return;
    const { w, h } = dims;
    if (w === 0 || h === 0) return;
    // Same shape-lock convention as the pointer drag.
    const renderAsCircle = (gradient.shape === "circle") !== e.shiftKey;
    const stepRatio = e.shiftKey ? 0.05 : 0.01;

    if (renderAsCircle) {
      // Resolve the current radius in pixels regardless of which field is
      // populated, then nudge in pixel space.
      const currentPx =
        gradient.radiusPx ??
        (gradient.radii
          ? Math.max(gradient.radii.x * w, gradient.radii.y * h)
          : keywordToRadii(
              gradient.shape,
              gradient.size,
              gradient.center,
              w,
              h,
            ).x * w);
      const stepPx = stepRatio * Math.min(w, h);
      let next = currentPx;
      if (e.key === "ArrowLeft" || e.key === "ArrowDown") next -= stepPx;
      else if (e.key === "ArrowRight" || e.key === "ArrowUp") next += stepPx;
      else return;
      e.preventDefault();
      ctx.setRadiusPx(Math.max(0, next));
      return;
    }

    // Ellipse path: per-axis nudge in normalized space, same as before.
    const current =
      gradient.radii ??
      keywordToRadii(gradient.shape, gradient.size, gradient.center, w, h);
    let { x, y } = current;
    if (e.key === "ArrowLeft") x -= stepRatio;
    else if (e.key === "ArrowRight") x += stepRatio;
    else if (e.key === "ArrowUp") y -= stepRatio;
    else if (e.key === "ArrowDown") y += stepRatio;
    else return;
    e.preventDefault();
    ctx.setRadii({
      x: Math.max(0, Math.min(2, x)),
      y: Math.max(0, Math.min(2, y)),
    });
  };

  const onKeyDownCenter = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (gradient.type === "linear") return;
    const center = gradient.center;
    const step = e.shiftKey ? 0.05 : 0.01;
    let { x, y } = center;
    if (e.key === "ArrowLeft") x -= step;
    else if (e.key === "ArrowRight") x += step;
    else if (e.key === "ArrowUp") y -= step;
    else if (e.key === "ArrowDown") y += step;
    else if (e.key === "Home") {
      x = 0.5;
      y = 0.5;
    } else return;
    e.preventDefault();
    const next = {
      x: Math.max(0, Math.min(1, x)),
      y: Math.max(0, Math.min(1, y)),
    };
    ctx.setCenter(next);
    announce(
      `Center x ${Math.round(next.x * 100)}%, y ${Math.round(next.y * 100)}%`,
    );
  };

  const beginDrag = (
    kind: HandleKind,
    e: React.PointerEvent<HTMLButtonElement>,
  ) => {
    e.preventDefault();
    e.stopPropagation();
    handleAt(kind, localFromEvent(e.clientX, e.clientY), e.shiftKey);
    trackPointerDrag(e.currentTarget, e.pointerId, (ev) =>
      handleAt(kind, localFromEvent(ev.clientX, ev.clientY), ev.shiftKey),
    );
  };

  // Radius handle ARIA. The pixel radius of a circle is unbounded (it can
  // exceed the box), and keyword circles normalized to width can pass 200%
  // on tall boxes — so the max grows with the value instead of letting
  // aria-valuenow overshoot a fixed aria-valuemax.
  const radiusAria = (() => {
    if (gradient.type !== "radial" || dims.w === 0 || dims.h === 0) return null;
    if (gradient.shape === "circle" && gradient.radiusPx !== undefined) {
      const now = Math.round(gradient.radiusPx);
      return {
        now,
        max: Math.max(now, Math.round(Math.hypot(dims.w, dims.h))),
        text: `circle radius ${now} pixels`,
      };
    }
    const r =
      gradient.radii ??
      keywordToRadii(
        gradient.shape,
        gradient.size,
        gradient.center,
        dims.w,
        dims.h,
      );
    const now = Math.round(r.x * 100);
    return {
      now,
      max: Math.max(200, now),
      text: `radius x ${now}%, y ${Math.round(r.y * 100)}%`,
    };
  })();

  // Render ------------------------------------------------------------------

  return (
    <div
      ref={padRef}
      data-slot="gradient-overlay"
      style={style}
      className={cn(
        // Fills the host container; pointer-events:none so empty regions
        // fall through to whatever sits beneath (consumer's canvas, the
        // <Area>'s painted bg, etc.). The handle buttons explicitly set
        // pointer-events:auto so they still receive clicks.
        "pointer-events-none absolute inset-0",
        className,
      )}
      {...rest}
    >
      <span aria-live="polite" className="sr-only">
        {liveText}
      </span>
      {handles && (
        <>
          {handles.showConnector && handles.b && (
            <svg
              aria-hidden
              className="pointer-events-none absolute inset-0"
              width={dims.w}
              height={dims.h}
            >
              <line
                x1={handles.a.x}
                y1={handles.a.y}
                x2={handles.b.x}
                y2={handles.b.y}
                stroke="white"
                strokeWidth={1.5}
                strokeDasharray="4 4"
                strokeOpacity={0.85}
                style={{
                  filter: "drop-shadow(0 0 1px rgba(0,0,0,0.55))",
                }}
              />
            </svg>
          )}

          {gradient.type === "radial" && handles.b && (
            <svg
              aria-hidden
              className="pointer-events-none absolute inset-0"
              width={dims.w}
              height={dims.h}
            >
              {(() => {
                const ax = handles.a.x;
                const ay = handles.a.y;
                const rx = Math.abs(handles.b.x - ax);
                const ry = Math.abs(handles.b.y - ay);
                // shape="circle" always renders a true circle outline so
                // radiusPx-circles (where the edge handle sits on the x
                // axis and ry === 0) don't collapse to a flat line. For
                // the keyword-circle path, rx === ry already.
                const isCircle = gradient.shape === "circle";
                const stroke = {
                  fill: "none",
                  stroke: "white",
                  strokeWidth: 1.5,
                  strokeDasharray: "4 4",
                  strokeOpacity: 0.85,
                  style: { filter: "drop-shadow(0 0 1px rgba(0,0,0,0.55))" },
                } as const;
                return isCircle ? (
                  <circle cx={ax} cy={ay} r={Math.max(rx, ry)} {...stroke} />
                ) : (
                  <ellipse cx={ax} cy={ay} rx={rx} ry={ry} {...stroke} />
                );
              })()}
            </svg>
          )}

          {gradient.type === "linear" ? (
            <>
              {/* Free-positioned endpoints move in 2D (x/y), so they must
                 not claim role="slider" — its 1D value model would keep
                 announcing the angle while arrows change position. They
                 become labelled 2D pads like the center handle; angle-only
                 mode keeps the true slider semantics. */}
              <Handle
                label={
                  gradient.start
                    ? `Gradient start, x ${Math.round(gradient.start.x * 100)}%, y ${Math.round(gradient.start.y * 100)}%`
                    : "Gradient start"
                }
                position={handles.a}
                onPointerDown={(event) => beginDrag("linear-a", event)}
                onKeyDown={(e) => onKeyDownLinearEndpoint("linear-a", e)}
                {...(gradient.start
                  ? {
                      role: "application" as const,
                      "aria-roledescription": "2D pad for gradient endpoint",
                    }
                  : {
                      role: "slider" as const,
                      "aria-valuemin": 0,
                      "aria-valuemax": 360,
                      "aria-valuenow": Math.round(gradient.angle),
                      "aria-valuetext": `${Math.round(gradient.angle)} degrees`,
                    })}
                // Endpoint handles are always anchored to the first / last
                // stop, so they render in that stop's color.
                style={
                  ctx.stops[0] ? stopSwatchStyle(ctx.stops[0].color) : undefined
                }
              />
              {handles.b && (
                <Handle
                  label={
                    gradient.end
                      ? `Gradient end, x ${Math.round(gradient.end.x * 100)}%, y ${Math.round(gradient.end.y * 100)}%`
                      : "Gradient end"
                  }
                  position={handles.b}
                  onPointerDown={(event) => beginDrag("linear-b", event)}
                  onKeyDown={(e) => onKeyDownLinearEndpoint("linear-b", e)}
                  {...(gradient.end
                    ? {
                        role: "application" as const,
                        "aria-roledescription": "2D pad for gradient endpoint",
                      }
                    : {
                        role: "slider" as const,
                        "aria-valuemin": 0,
                        "aria-valuemax": 360,
                        "aria-valuenow": Math.round(gradient.angle),
                        "aria-valuetext": `${Math.round(gradient.angle)} degrees`,
                      })}
                  style={(() => {
                    const last = ctx.stops[ctx.stops.length - 1];
                    return last ? stopSwatchStyle(last.color) : undefined;
                  })()}
                />
              )}
              {/* Middle stops are intentionally NOT rendered on the canvas:
                 the Bar is the editing surface for inner stops. The overlay
                 surfaces only the gradient *geometry* — the two endpoints
                 (tinted with the first / last stop colors) and the line. */}
            </>
          ) : (
            <>
              <Handle
                label={`Gradient center, x ${Math.round(gradient.center.x * 100)}%, y ${Math.round(gradient.center.y * 100)}%`}
                position={handles.a}
                onPointerDown={(event) => beginDrag("center", event)}
                onKeyDown={onKeyDownCenter}
                role="application"
                aria-roledescription="2D pad for gradient center"
              />
              {handles.b && gradient.type === "conic" && (
                <Handle
                  label="Gradient start angle"
                  position={handles.b}
                  onPointerDown={(event) => beginDrag("conic-dial", event)}
                  onKeyDown={onKeyDownConicDial}
                  role="slider"
                  aria-valuemin={0}
                  aria-valuemax={360}
                  aria-valuenow={Math.round(gradient.startAngle)}
                  aria-valuetext={`${Math.round(gradient.startAngle)} degrees`}
                />
              )}
              {handles.b && radiusAria && (
                <Handle
                  label="Gradient radius"
                  position={handles.b}
                  onPointerDown={(event) => beginDrag("radial-edge", event)}
                  onKeyDown={onKeyDownRadii}
                  role="slider"
                  aria-valuemin={0}
                  aria-valuemax={radiusAria.max}
                  aria-valuenow={radiusAria.now}
                  aria-valuetext={radiusAria.text}
                />
              )}
            </>
          )}
        </>
      )}
    </div>
  );
});

interface HandleProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> {
  label: string;
  position: XY;
}

function Handle({ label, position, className, style, ...rest }: HandleProps) {
  return (
    <button
      type="button"
      aria-label={label}
      tabIndex={0}
      style={{
        left: position.x,
        top: position.y,
        width: HANDLE_PX,
        height: HANDLE_PX,
        ...style,
      }}
      className={cn(
        "absolute -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none rounded-full",
        // WCAG 2.5.8: 14px visual dot, ≥24px pointer target.
        "before:absolute before:-inset-1.5 before:content-['']",
        // Solid background so the dashed gradient line behind the dot is
        // fully hidden by it (a translucent fill would let the line bleed
        // through visibly). `pointer-events-auto` so the buttons keep
        // receiving clicks even when their parent overlay is set to
        // `pointer-events-none` (the default for `<GradientPicker.Overlay>`).
        "pointer-events-auto border-2 border-white bg-background shadow-[0_0_0_1px_rgba(0,0,0,0.55),0_2px_6px_rgba(0,0,0,0.35)]",
        "outline-none motion-safe:transition-transform",
        "motion-safe:hover:scale-110 active:cursor-grabbing motion-safe:active:scale-95",
        "focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
      {...rest}
    />
  );
}
registry/new-york/ui/fill-picker/parts/gradient/area.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { formatGradient } from "../../lib/gradient";
import { CHECKERBOARD_LG } from "../../lib/constants";
import { Overlay, type OverlayProps } from "./overlay";

export interface AreaProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Fixed height in px. Defaults to 120. Width always fills the container. */
  height?: number;
  /** Forwarded to the inner `<GradientPicker.Overlay>` for conic dial sizing. */
  conicDialRadius?: OverlayProps["conicDialRadius"];
}

/**
 * Self-contained 2D pad: paints the live gradient (over a transparency
 * checkerboard) and overlays the handle layer on top. Essentially
 * `<GradientPicker.Overlay>` plus a painted background — use this when you
 * want the picker to own its own preview, and use `<GradientPicker.Overlay>`
 * directly when you want to drop the handles onto a consumer-owned object
 * on a canvas.
 */
export const Area = React.forwardRef<HTMLDivElement, AreaProps>(function Area(
  { className, height = 120, conicDialRadius, style, ...rest },
  ref,
) {
  const ctx = useGradientPickerContext();
  const cssBackground = React.useMemo(
    () => formatGradient(ctx.gradient),
    [ctx.gradient],
  );

  return (
    <div
      ref={ref}
      data-slot="gradient-area"
      style={{ height, ...style }}
      className={cn(
        "relative w-full overflow-hidden rounded-md border border-border bg-muted",
        className,
      )}
      {...rest}
    >
      {/* Transparency checker behind the gradient so alpha is legible. */}
      <div
        aria-hidden
        className="absolute inset-0"
        style={{ backgroundImage: CHECKERBOARD_LG }}
      />
      <div
        aria-hidden
        className="absolute inset-0"
        style={{ background: cssBackground }}
      />
      <Overlay conicDialRadius={conicDialRadius} />
    </div>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/pointer-drag.ts
"use client";

/**
 * Capture-based drag loop shared by the overlay handles and the angle /
 * position pads. Besides the usual pointerup/pointercancel cleanup it
 * defends against the two ways a drag gets "stuck" chasing the cursor:
 *
 *  - a move arriving with no buttons pressed (the release happened where
 *    we couldn't see it — e.g. capture silently dropped, button released
 *    during an OS gesture) ends the drag instead of dragging on;
 *  - `lostpointercapture` (window blur / cmd-tab mid-drag revokes capture
 *    without any pointerup) tears the listeners down immediately.
 */
export function trackPointerDrag(
  target: HTMLElement,
  pointerId: number,
  onMove: (ev: PointerEvent) => void,
): void {
  try {
    target.setPointerCapture(pointerId);
  } catch {
    // inactive pointer id — the drag still works while the pointer stays
    // over the target; the buttons check below handles missed releases.
  }
  const move = (ev: PointerEvent) => {
    // Only the dragging pointer steers or ends the gesture — a second
    // touch point wandering over the captured element must not.
    if (ev.pointerId !== pointerId) return;
    if (ev.buttons === 0) {
      end(ev);
      return;
    }
    onMove(ev);
  };
  const end = (ev?: Event) => {
    if (ev instanceof PointerEvent && ev.pointerId !== pointerId) return;
    if (ev instanceof PointerEvent) {
      try {
        target.releasePointerCapture(ev.pointerId);
      } catch {
        // pointer may already be released on cancel
      }
    }
    target.removeEventListener("pointermove", move);
    target.removeEventListener("pointerup", end);
    target.removeEventListener("pointercancel", end);
    target.removeEventListener("lostpointercapture", end);
  };
  target.addEventListener("pointermove", move);
  target.addEventListener("pointerup", end);
  target.addEventListener("pointercancel", end);
  target.addEventListener("lostpointercapture", end);
}
registry/new-york/ui/fill-picker/parts/gradient/stop-list.tsx
"use client";

import * as React from "react";
import { Plus, Minus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
  useGradientPickerContext,
  useGradientStopEditor,
} from "../../contexts/gradient";
import {
  projectStopPosition,
  reverseProjectStopPosition,
} from "../../lib/gradient";
import {
  focusNeighborOption,
  insertStopAfterSelected,
  isEventFromRowControl,
  stopListKeyNav,
} from "./stop-list-shared";
import { formatColor, parseColor } from "../../lib/color";
import { CHECKERBOARD_SM } from "../../lib/constants";
import {
  FieldDraftInput,
  FieldInput,
  FieldInputGroup,
  FieldShell,
  FieldSuffix,
} from "../field";

export interface StopListProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * Render a trailing "+ Add stop" row that inserts a new stop in the
   * largest gap between adjacent stops, sampling the gradient's existing
   * ramp at that position so the new color blends in. Defaults to `true`.
   * Set `false` to hide it when `<GradientPicker.Bar>` (the canonical
   * add-stop UI) is visible and the trailing row would be redundant.
   */
  showAddStop?: boolean;
}

export const StopList = React.forwardRef<HTMLDivElement, StopListProps>(
  function StopList(
    { className, showAddStop = true, ...rest },
    ref,
  ) {
  const ctx = useGradientPickerContext();
  // Each row mounts its own copy of the injected stop editor (bound to that
  // row's stop) so opening any popover edits the right stop directly — no
  // need for a list-wide ColorPicker context.
  // Mirror the Bar's projection: when the gradient is a positioned linear,
  // show + edit the *visible* position so this list matches what the user
  // sees in the Area and Bar. Authored positions still live in 0..1 of the
  // [start, end] segment; we convert at the edges.
  const linear = ctx.gradient.type === "linear" ? ctx.gradient : null;
  const start = linear?.start;
  const end = linear?.end;
  const toDisplay = (authored: number) =>
    projectStopPosition(authored, start, end);
  const fromDisplay = (displayed: number) =>
    reverseProjectStopPosition(displayed, start, end);

  const handleAddStop = () => {
    const { position, color } = insertStopAfterSelected(
      ctx.stops,
      ctx.selectedStopId,
    );
    const id = ctx.addStop(position, color);
    ctx.selectStop(id);
  };
  return (
    <div
      ref={ref}
      data-slot="gradient-stop-list"
      role="listbox"
      aria-label="Gradient stops"
      onKeyDown={stopListKeyNav}
      className={cn("flex flex-col gap-1", className)}
      {...rest}
    >
      {ctx.stops.map((s) => {
        const selected = s.id === ctx.selectedStopId;
        return (
          <StopRow
            key={s.id}
            stop={s}
            selected={selected}
            toDisplay={toDisplay}
            fromDisplay={fromDisplay}
            formatted={formatColor(s.color, ctx.getStopColorFormat(s.id))}
          />
        );
      })}
      {showAddStop && (
        <Button
          type="button"
          variant="outline"
          onClick={handleAddStop}
          aria-label="Add stop"
          className="h-8 cursor-pointer font-mono text-xs tracking-wide shadow-xs"
        >
          <Plus aria-hidden className="size-3.5" />
          Add stop
        </Button>
      )}
    </div>
  );
});

function StopRow({
  stop: s,
  selected,
  toDisplay,
  fromDisplay,
  formatted,
}: {
  stop: ReturnType<typeof useGradientPickerContext>["stops"][number];
  selected: boolean;
  toDisplay: (n: number) => number;
  fromDisplay: (n: number) => number;
  formatted: string;
}) {
  const ctx = useGradientPickerContext();
  // Same slot the Bar uses: the editor's UI dialect ships per variant and is
  // injected by the barrel, so this list stays importable from either tree
  // without dragging one dialect's primitives in.
  const stopEditor = useGradientStopEditor();
  const [open, setOpen] = React.useState(false);
  const [draft, setDraft] = React.useState(formatted);
  const focusedRef = React.useRef(false);
  // Escape reverts by blurring, and that blur would otherwise commit the
  // still-typed DOM value — the reverted draft hasn't rendered yet.
  const cancelBlurCommitRef = React.useRef(false);
  React.useEffect(() => {
    if (!focusedRef.current) setDraft(formatted);
  }, [formatted]);
  const commitDraft = (raw: string) => {
    const parsed = parseColor(raw.trim());
    if (parsed) ctx.setStopColor(s.id, parsed);
    else setDraft(formatted);
  };
  const swatch = (
    <button
      type="button"
      onClick={(e) => {
        e.stopPropagation();
        ctx.selectStop(s.id);
        setOpen((o) => !o);
      }}
      aria-label="Edit stop color"
      style={{
        backgroundImage: `linear-gradient(${formatColor(s.color, "oklch")}, ${formatColor(s.color, "oklch")}), ${CHECKERBOARD_SM}`,
        backgroundSize: "auto, 6px 6px",
      }}
      className="size-7 shrink-0 rounded-xs border border-border outline-none transition-shadow hover:ring-2 hover:ring-ring focus-visible:ring-2 focus-visible:ring-ring"
    />
  );
  return (
    <div
      role="option"
      aria-selected={selected}
      tabIndex={selected ? 0 : -1}
      data-selected={selected}
      onClick={() => ctx.selectStop(s.id)}
      onKeyDown={(e) => {
        // Only when the option row itself is focused — keys typed into the
        // nested inputs (e.g. Backspace while editing the color text) must
        // never trigger row shortcuts.
        if (isEventFromRowControl(e)) return;
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          ctx.selectStop(s.id);
        } else if (e.key === "Delete" || e.key === "Backspace") {
          e.preventDefault();
          // Focus the neighbor before removal so focus stays in the list.
          focusNeighborOption(e.currentTarget);
          ctx.removeStop(s.id);
        }
      }}
      className={cn(
        "flex cursor-pointer items-center gap-2 rounded-md border p-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring",
        selected ? "border-foreground" : "border-border",
      )}
    >
      {stopEditor
        ? stopEditor({
            stopId: s.id,
            open,
            onOpenChange: setOpen,
            children: swatch,
          })
        : swatch}
      <FieldShell className="h-7 w-fit">
        <FieldInputGroup>
          <span className="sr-only">Stop position</span>
          <FieldDraftInput
            inputMode="decimal"
            nudge={1}
            value={String(Math.round(toDisplay(s.position) * 100))}
            onCommit={(raw) => {
              const v = parseFloat(raw);
              // Clamp the typed *displayed* percent to the track; moveStop
              // itself no longer clamps (extrapolated stops are legal).
              if (Number.isFinite(v))
                ctx.moveStop(
                  s.id,
                  fromDisplay(Math.max(0, Math.min(1, v / 100))),
                );
            }}
            aria-label="Stop position"
            className="w-10"
          />
          <FieldSuffix>%</FieldSuffix>
        </FieldInputGroup>
      </FieldShell>
      <FieldShell className="h-7 min-w-0 flex-1">
        <FieldInputGroup>
          <span className="sr-only">Stop color (paste hex / css)</span>
          <FieldInput
            value={draft}
            spellCheck={false}
            onFocus={() => {
              focusedRef.current = true;
            }}
            onClick={(e) => e.stopPropagation()}
            onChange={(e) => setDraft(e.target.value)}
            onBlur={(e) => {
              focusedRef.current = false;
              if (cancelBlurCommitRef.current) {
                cancelBlurCommitRef.current = false;
                return;
              }
              commitDraft(e.target.value);
            }}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                commitDraft((e.target as HTMLInputElement).value);
                (e.target as HTMLInputElement).blur();
              } else if (e.key === "Escape") {
                e.preventDefault();
                setDraft(formatted);
                cancelBlurCommitRef.current = true;
                (e.target as HTMLInputElement).blur();
              }
            }}
            aria-label="Stop color value"
            className="text-left"
          />
        </FieldInputGroup>
      </FieldShell>
      <button
        type="button"
        onClick={(e) => {
          e.stopPropagation();
          const row = e.currentTarget.closest<HTMLElement>('[role="option"]');
          if (row) focusNeighborOption(row);
          ctx.removeStop(s.id);
        }}
        disabled={ctx.stops.length <= 1}
        className="inline-flex size-7 items-center justify-center rounded-md border border-input text-muted-foreground shadow-xs hover:text-foreground disabled:opacity-30"
        aria-label="Remove stop"
      >
        <Minus className="size-3.5" />
      </button>
    </div>
  );
}
registry/new-york/ui/fill-picker/parts/gradient/stop-list-shared.ts
"use client";

/**
 * Behavior shared by the two StopList variants (classic and Base UI) so
 * keyboard/list logic can't drift between them: listbox arrow navigation,
 * focus retention on delete, and the insert-after-selected placement math.
 */

import * as React from "react";
import { sampleStopsAt } from "../../lib/gradient";
import type { OklchColor } from "../../lib/types";

/**
 * True when the key event originated inside a form control nested in the
 * option row (position/color inputs, popover trigger, remove button) rather
 * than on the option itself. List/row shortcuts must ignore those — e.g.
 * Backspace while editing the color text must edit text, not delete the
 * stop; ArrowUp/Down must nudge the numeric field, not move list focus.
 */
export function isEventFromRowControl(
  e: React.KeyboardEvent<HTMLElement>,
): boolean {
  return e.target !== e.currentTarget;
}

/**
 * Listbox keyboard navigation for the stop list container: ArrowUp/Down
 * move to the neighboring option, Home/End jump to the edges. Selection
 * follows focus — activation goes through `.click()` so the row's own
 * onClick (selectStop) runs.
 */
export function stopListKeyNav(e: React.KeyboardEvent<HTMLElement>): void {
  if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
  // Only navigate when the key originated on an option row itself. Keys
  // from nested controls (position/color inputs — where ArrowUp/Down mean
  // "nudge the value") must never be stolen by list navigation.
  if (!isOption(e.target)) return;
  const options = Array.from(
    e.currentTarget.querySelectorAll<HTMLElement>('[role="option"]'),
  );
  if (options.length === 0) return;
  const current = options.indexOf(document.activeElement as HTMLElement);
  let next: number;
  if (e.key === "Home") next = 0;
  else if (e.key === "End") next = options.length - 1;
  else {
    const delta = e.key === "ArrowDown" ? 1 : -1;
    next = Math.max(0, Math.min(Math.max(current, 0) + delta, options.length - 1));
  }
  e.preventDefault();
  options[next].focus();
  options[next].click();
}

function isOption(target: EventTarget | null): boolean {
  return (
    target instanceof HTMLElement && target.getAttribute("role") === "option"
  );
}

/**
 * Move focus to the row's nearest option sibling. Call *before* removing
 * the stop — the neighbor's DOM node survives the re-render, so focus
 * stays in the list instead of being ejected to <body>.
 */
export function focusNeighborOption(row: HTMLElement): void {
  const neighbor =
    findOptionSibling(row, "nextElementSibling") ??
    findOptionSibling(row, "previousElementSibling");
  neighbor?.focus();
}

/**
 * Same intent as `focusNeighborOption`, but scans a container by selector
 * instead of walking siblings — the `<GradientPicker.Bar>` handles are
 * `role="slider"` elements that may each sit inside a popover anchor wrapper,
 * so they are not reliably DOM siblings of one another.
 *
 * Call *before* removing the focused element. Returns true when focus moved.
 */
export function focusNeighborIn(
  container: HTMLElement | null,
  current: HTMLElement,
  selector: string,
): boolean {
  if (!container) return false;
  const items = Array.from(container.querySelectorAll<HTMLElement>(selector));
  const idx = items.indexOf(current);
  if (idx === -1) return false;
  const neighbor = items[idx + 1] ?? items[idx - 1];
  if (!neighbor) return false;
  neighbor.focus();
  return true;
}

function findOptionSibling(
  el: HTMLElement,
  dir: "nextElementSibling" | "previousElementSibling",
): HTMLElement | null {
  let node = el[dir];
  while (node) {
    if (node instanceof HTMLElement && node.getAttribute("role") === "option") {
      return node;
    }
    node = node[dir];
  }
  return null;
}

/**
 * Placement for the "+ Add stop" row: insert immediately after the selected
 * stop — halfway to its next neighbor, or halfway back to the previous one
 * when the selected stop is last (so a quick add lands somewhere visible
 * instead of squeezing into a near-zero end gap). Samples the existing ramp
 * so the inserted color blends in.
 */
export function insertStopAfterSelected<
  T extends { id: string; position: number; color: OklchColor },
>(stops: readonly T[], selectedStopId: string | null): {
  position: number;
  color: OklchColor;
} {
  const sorted = [...stops].sort((a, b) => a.position - b.position);
  // A stop-less gradient is degenerate but type-legal (`Gradient.stops` has no
  // non-empty constraint), so a controlled consumer can hand us one. There is
  // no anchor to place relative to — drop the first stop at 0 and let
  // sampleStopsAt supply its neutral fallback color.
  if (sorted.length === 0) return { position: 0, color: sampleStopsAt(sorted, 0) };
  const selectedIdx = sorted.findIndex((x) => x.id === selectedStopId);
  const anchorIdx = selectedIdx === -1 ? sorted.length - 1 : selectedIdx;
  const anchor = sorted[anchorIdx];
  const next = sorted[anchorIdx + 1];
  const prev = sorted[anchorIdx - 1];
  const position = next
    ? (anchor.position + next.position) / 2
    : prev
      ? (prev.position + anchor.position) / 2
      : Math.min(1, (anchor.position + 1) / 2);
  return { position, color: sampleStopsAt(sorted, position) };
}
registry/new-york/ui/fill-picker/parts/gradient/stop-color.tsx
"use client";

import * as React from "react";
import { ColorPickerContext } from "../../context";
import { useGradientPickerContext } from "../../contexts/gradient";
import { useColorPicker } from "../../hooks/use-color-picker";
import type { OklchColor } from "../../lib/types";

/**
 * Mounts ColorPickerContext bound to the selected stop's color. Children are
 * normal ColorPicker.* parts (Area, Hue, ChannelInput, ...) — they Just Work
 * because they only depend on ColorPickerContext.
 *
 * The inner Bound component is keyed on the selected stop id, so a fresh
 * useColorPicker instance (and a fresh `lastGoodHueRef`) is created per
 * stop. This isolates hue preservation per stop: editing stop A then
 * switching to stop B and back to A doesn't smear hue history across stops.
 */
export const StopColor: React.FC<React.PropsWithChildren> = ({ children }) => {
  const grad = useGradientPickerContext();
  const stop = grad.selectedStop;
  if (!stop) return null;
  return (
    <Bound key={stop.id} stopId={stop.id} initialColor={stop.color}>
      {children}
    </Bound>
  );
};

const Bound: React.FC<
  React.PropsWithChildren<{ stopId: string; initialColor: OklchColor }>
> = ({ stopId, initialColor, children }) => {
  const grad = useGradientPickerContext();
  // Re-read the live color on every render so external moves (e.g. preset
  // applied) flow into the picker. Memoize on the channel values (not on
  // grad.stops identity) so the controlled value handed to useColorPicker is
  // ref-stable across renders that don't actually change this stop's color.
  // Without this, every gradient update — even ones that don't touch this
  // stop — would hand the inner ColorPicker a fresh OklchColor object, which
  // turns Area's `[color]` effect into a per-render trigger and can spiral
  // into Maximum-update-depth under React 19's stricter detection.
  const found = grad.stops.find((s) => s.id === stopId);
  const l = found?.color.l ?? initialColor.l;
  const c = found?.color.c ?? initialColor.c;
  const h = found?.color.h ?? initialColor.h;
  const alpha = found?.color.alpha ?? initialColor.alpha;
  const liveColor = React.useMemo<OklchColor>(
    () => ({ l, c, h, alpha }),
    [l, c, h, alpha],
  );

  const setStopColor = grad.setStopColor;
  const onValueChange = React.useCallback(
    (color: OklchColor) => setStopColor(stopId, color),
    [setStopColor, stopId],
  );

  const format = grad.getStopColorFormat(stopId);
  const onFormatChange = React.useCallback(
    (f: typeof format) => grad.setStopColorFormat(stopId, f),
    [grad, stopId],
  );
  const state = useColorPicker({
    value: liveColor,
    onValueChange,
    format,
    onFormatChange,
  });

  return (
    <ColorPickerContext.Provider value={state}>
      {children}
    </ColorPickerContext.Provider>
  );
};
registry/new-york/ui/fill-picker/parts/gradient/stop-popover.tsx
"use client";

import * as React from "react";
import { Popover } from "@base-ui/react/popover";
import { cn } from "@/lib/utils";

export interface StopPopoverProps {
  /** Controlled open — the caller owns it; this is never a self-triggering popover. */
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /**
   * The element to position against, rendered in place. It is NOT a trigger:
   * these anchors (Bar drag handles, StopList swatches) own their own pointer
   * handling, so open is driven externally and we anchor via Base UI's
   * Positioner `anchor` prop rather than a Radix-style `PopoverAnchor`.
   */
  anchor: React.ReactElement;
  side?: "top" | "bottom" | "left" | "right";
  align?: "start" | "center" | "end";
  sideOffset?: number;
  className?: string;
  children: React.ReactNode;
  onContentClick?: React.MouseEventHandler<HTMLDivElement>;
}

/**
 * Small self-contained popover built directly on `@base-ui/react/popover`.
 *
 * Deliberately does NOT depend on the consumer's `@/components/ui/popover`:
 * shadcn's Base UI popover omits an anchor/asChild surface, so the
 * anchor-without-trigger pattern this editor needs isn't expressible through
 * it. Owning this thin shell keeps the stop editor identical across the Radix
 * and Base UI variants.
 */
export function StopPopover({
  open,
  onOpenChange,
  anchor,
  side = "bottom",
  align = "center",
  sideOffset = 8,
  className,
  children,
  onContentClick,
}: StopPopoverProps) {
  const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
  const child = React.Children.only(anchor);

  return (
    <>
      {/* Render the anchor in place; capture its DOM node to position against.
          These anchors carry no ref of their own, so a plain override is safe. */}
      {React.cloneElement(child as React.ReactElement<{ ref?: React.Ref<HTMLElement> }>, {
        ref: setAnchorEl,
      })}
      <Popover.Root open={open} onOpenChange={(next) => onOpenChange(next)}>
        <Popover.Portal>
          <Popover.Positioner
            anchor={anchorEl}
            side={side}
            align={align}
            sideOffset={sideOffset}
            className="z-50"
          >
            <Popover.Popup
              onClick={onContentClick}
              className={cn(
                "origin-[var(--transform-origin)] rounded-md border border-border bg-popover p-3 text-popover-foreground shadow-md outline-none",
                className,
              )}
            >
              {children}
            </Popover.Popup>
          </Popover.Positioner>
        </Popover.Portal>
      </Popover.Root>
    </>
  );
}
registry/new-york/ui/fill-picker/parts/gradient/stop-editor-shared.ts
"use client";

import * as React from "react";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
  useColorPicker,
  type ColorPickerState,
} from "../../hooks/use-color-picker";

/**
 * Tooltip/popover-agnostic half of the per-stop color editor: a
 * `useColorPicker` bound to one gradient stop's color and per-stop format.
 *
 * Shared by both variants' stop editors (`stop-editor-popover.tsx` here and
 * `fill-picker-base/parts/gradient/stop-editor.tsx`) so the memoization
 * subtleties below can't drift between them — mirrors the
 * `*-shared` split used by GamutBadge / ContrastReadout.
 */
export function useStopColorPickerState(stopId: string): ColorPickerState {
  const grad = useGradientPickerContext();
  const stop = grad.stops.find((s) => s.id === stopId) ?? grad.stops[0];
  const format = grad.getStopColorFormat(stopId);
  // Memoize on the channel values, not on `stop` identity — same reasoning as
  // `<GradientPicker.StopColor>`'s Bound. The gradient hook allocates fresh
  // stop objects on every controlled sync (even a structural match), so
  // passing `stop?.color` straight through would hand the inner picker a new
  // OklchColor identity on each parent render, turning Area's `[color]`
  // effect into a per-render trigger.
  const stopL = stop?.color.l;
  const stopC = stop?.color.c;
  const stopH = stop?.color.h;
  const stopAlpha = stop?.color.alpha;
  const liveColor = React.useMemo(
    () =>
      stopL === undefined ||
      stopC === undefined ||
      stopH === undefined ||
      stopAlpha === undefined
        ? undefined
        : { l: stopL, c: stopC, h: stopH, alpha: stopAlpha },
    [stopL, stopC, stopH, stopAlpha],
  );
  return useColorPicker({
    value: liveColor,
    onValueChange: (c) => {
      if (stop) grad.setStopColor(stop.id, c);
    },
    format,
    onFormatChange: (f) => grad.setStopColorFormat(stopId, f),
  });
}
registry/new-york/ui/fill-picker/parts/gradient/reverse-stops.tsx
"use client";

import * as React from "react";
import { ArrowLeftRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";

/**
 * Icon button that mirrors every stop's position around 0.5, flipping the
 * gradient's visual order while preserving stop ids and colors. Disabled
 * when there are fewer than 2 stops (nothing meaningful to reverse).
 *
 * Styled with the shared shadcn `<Button variant="outline" size="icon-sm">`
 * so it matches `<ColorPicker.EyeDropper>` and the rest of the new-york
 * icon-button family.
 *
 * Reads from `<GradientPicker.Root>` context — throws if rendered outside.
 */
export const ReverseStops = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement>
>(function ReverseStops({ className, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  const disabled = ctx.stops.length < 2;
  return (
    <Button
      ref={ref}
      type="button"
      variant="outline"
      size="icon-sm"
      data-slot="gradient-reverse-stops"
      onClick={ctx.reverseStops}
      disabled={disabled}
      aria-label="Reverse stop order"
      className={cn("cursor-pointer", className)}
      {...rest}
    >
      <ArrowLeftRight aria-hidden="true" className="size-4" />
    </Button>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/repeating-toggle.tsx
"use client";

import * as React from "react";
import { Repeat } from "lucide-react";
import { Toggle } from "@/components/ui/toggle";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";

/**
 * Icon toggle that flips the active gradient between its standard and
 * `repeating-*` CSS form. The stop ramp tiles instead of stretching to
 * fill the box — useful for stripes, barberpoles.
 *
 * Built on shadcn `<Toggle variant="outline" size="sm">` so the pressed
 * state is wired by Base UI (`data-pressed`) and the visual treatment
 * matches every other toggle the consumer might already have. We tighten
 * the lit-border affordance over the base variant.
 *
 * Hidden when `gradient.type === "conic"` — conic already sweeps a full
 * 360°, so `repeating-conic-gradient` only matters for niche pie-slice
 * patterns; not worth a top-level toggle.
 *
 * Reads from `<GradientPicker.Root>` context — throws if rendered outside.
 */
export const RepeatingToggle = React.forwardRef<
  HTMLButtonElement,
  React.ComponentProps<typeof Toggle>
>(function RepeatingToggle({ className, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  if (ctx.gradient.type === "conic") return null;
  const on = !!ctx.gradient.repeating;
  return (
    <Toggle
      ref={ref}
      variant="outline"
      size="sm"
      data-slot="gradient-repeating-toggle"
      pressed={on}
      onPressedChange={ctx.setRepeating}
      aria-label={on ? "Disable repeating gradient" : "Enable repeating gradient"}
      className={cn(
        "cursor-pointer text-muted-foreground data-[pressed]:border-foreground/70 data-[pressed]:bg-foreground/10 data-[pressed]:text-foreground",
        className,
      )}
      {...rest}
    >
      <Repeat aria-hidden="true" className="size-4" />
    </Toggle>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/presets.tsx
"use client";

import * as React from "react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
  formatGradient,
  parseGradient,
  type Gradient,
} from "../../lib/gradient";
import { SAMPLE_EDGE } from "../../lib/constants";

export const BUILTIN_GRADIENT_PRESETS: string[] = [
  "linear-gradient(in oklch 90deg, oklch(0.9 0.15 60) 0%, oklch(0.6 0.2 30) 100%)",
  "linear-gradient(in oklch 135deg, oklch(0.8 0.18 200) 0%, oklch(0.7 0.2 320) 100%)",
  "linear-gradient(in oklch 0deg, oklch(0.95 0.04 90) 0%, oklch(0.5 0.18 30) 100%)",
  "radial-gradient(circle farthest-corner at 50% 50% in oklch, oklch(0.9 0.2 280) 0%, oklch(0.3 0.12 260) 100%)",
  "conic-gradient(from 0deg at 50% 50% in oklch, oklch(0.7 0.18 0) 0%, oklch(0.7 0.18 90) 25%, oklch(0.7 0.18 180) 50%, oklch(0.7 0.18 270) 75%, oklch(0.7 0.18 360) 100%)",
];

export interface PresetsProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Override or extend the built-in preset list. */
  presets?: string[];
  /**
   * When provided, renders a "+" tile after the presets that calls this with
   * the current gradient and its serialized CSS. The consumer owns state
   * (lift `presets` and update it here, persist to localStorage / a server /
   * a store / whatever).
   */
  onAdd?: (gradient: Gradient, css: string) => void;
}

export const Presets = React.forwardRef<HTMLDivElement, PresetsProps>(
  function Presets(
    { presets = BUILTIN_GRADIENT_PRESETS, onAdd, className, ...rest },
    ref,
  ) {
    const ctx = useGradientPickerContext();
    const parsed = React.useMemo(
      () =>
        presets
          .map((css) => ({ css, gradient: parseGradient(css) }))
          .filter((p): p is { css: string; gradient: Gradient } => p.gradient !== null),
      [presets],
    );

    return (
      <div
        ref={ref}
        data-slot="gradient-presets"
        className={cn("flex flex-wrap gap-1.5", className)}
        {...rest}
      >
        {parsed.map(({ css, gradient }) => (
          <button
            key={css}
            type="button"
            onClick={() => ctx.setGradient(gradient)}
            className={cn(
              "relative size-8 cursor-pointer rounded-md outline-none transition-shadow",
              SAMPLE_EDGE,
              "focus-visible:ring-2 focus-visible:ring-ring hover:scale-110",
            )}
            style={{ background: formatGradient(gradient) }}
            aria-label="Apply gradient preset"
          />
        ))}
        {onAdd && (
          <button
            type="button"
            aria-label="Save current gradient as preset"
            onClick={() => onAdd(ctx.gradient, formatGradient(ctx.gradient))}
            className={cn(
              "inline-flex size-8 cursor-pointer items-center justify-center rounded-md border border-dashed border-border text-muted-foreground outline-none transition-colors",
              "hover:border-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
            )}
          >
            <Plus className="size-4" />
          </button>
        )}
      </div>
    );
  },
);
registry/new-york/ui/fill-picker/parts/gradient/css-input.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { formatGradient, parseGradient } from "../../lib/gradient";
import { FieldInput, FieldShell } from "../field";

export const CssInput = React.forwardRef<
  HTMLInputElement,
  React.InputHTMLAttributes<HTMLInputElement>
>(function CssInput({ className, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  const inputRef = React.useRef<HTMLInputElement | null>(null);
  React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
  const [draft, setDraft] = React.useState(() => formatGradient(ctx.gradient));
  const [invalid, setInvalid] = React.useState(false);
  // "The user has typed since the last sync/commit." Distinct from "the draft
  // differs from the current gradient": an external update while the field is
  // merely *focused* also makes those differ, and treating that as an edit is
  // what let a stale draft silently overwrite the newer value on blur.
  const [dirty, setDirty] = React.useState(false);

  // Adjust state during render rather than via useEffect — same pattern as the
  // color `CssInput`, `HexField`, and `ChannelField`. Re-sync the draft unless
  // there is an actual in-progress edit to clobber.
  const [prevGradient, setPrevGradient] = React.useState(ctx.gradient);
  if (ctx.gradient !== prevGradient) {
    setPrevGradient(ctx.gradient);
    const inputEl = inputRef.current;
    const focused =
      typeof document !== "undefined" &&
      inputEl !== null &&
      document.activeElement === inputEl;
    if (!focused || !dirty) {
      setDraft(formatGradient(ctx.gradient));
      setInvalid(false);
      setDirty(false);
    }
  }

  const commit = () => {
    // Nothing typed — the draft is just a mirror of the current gradient, so
    // committing it would at best be a no-op and at worst re-assert a value
    // that an external update has since replaced.
    if (!dirty) {
      setInvalid(false);
      return;
    }
    const parsed = parseGradient(draft);
    if (parsed) {
      ctx.setGradient(parsed);
      setInvalid(false);
      setDirty(false);
    } else {
      setInvalid(true);
    }
  };

  return (
    <FieldShell
      data-slot="gradient-css-input"
      className={cn(
        "w-full",
        invalid && "border-destructive focus-within:ring-destructive",
        className,
      )}
    >
      <FieldInput
        ref={inputRef}
        value={draft}
        onChange={(e) => {
          setDraft(e.target.value);
          setDirty(true);
        }}
        onBlur={commit}
        onKeyDown={(e) => {
          if (e.key === "Enter") commit();
        }}
        aria-invalid={invalid}
        aria-label="Gradient CSS"
        className="flex-1 px-2 text-left text-[10px]"
        {...rest}
      />
    </FieldShell>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/position-pad.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { trackPointerDrag } from "./pointer-drag";
import { useLiveAnnounce } from "../use-live-announce";

export interface PositionPadProps extends React.HTMLAttributes<HTMLDivElement> {
  size?: number;
}

export const PositionPad = React.forwardRef<HTMLDivElement, PositionPadProps>(
  function PositionPad({ className, size = 32, ...rest }, ref) {
    const ctx = useGradientPickerContext();
    const padRef = React.useRef<HTMLDivElement | null>(null);
    React.useImperativeHandle(ref, () => padRef.current as HTMLDivElement);
    // role="application" is silent under keyboard — announce moves politely.
    const [liveText, announce] = useLiveAnnounce();

    if (ctx.gradient.type === "linear") return null;
    const center = ctx.gradient.center;

    const fromEvent = (clientX: number, clientY: number) => {
      const el = padRef.current;
      if (!el) return center;
      const r = el.getBoundingClientRect();
      if (r.width === 0 || r.height === 0) return { x: 0, y: 0 };
      return {
        x: Math.max(0, Math.min(1, (clientX - r.left) / r.width)),
        y: Math.max(0, Math.min(1, (clientY - r.top) / r.height)),
      };
    };

    const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
      ctx.setCenter(fromEvent(e.clientX, e.clientY));
      trackPointerDrag(e.currentTarget, e.pointerId, (ev) =>
        ctx.setCenter(fromEvent(ev.clientX, ev.clientY)),
      );
    };

    const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
      const step = e.shiftKey ? 0.05 : 0.01;
      const clamp = (n: number) => Math.max(0, Math.min(1, n));
      let next: { x: number; y: number };
      if (e.key === "ArrowLeft") {
        next = { x: clamp(center.x - step), y: center.y };
      } else if (e.key === "ArrowRight") {
        next = { x: clamp(center.x + step), y: center.y };
      } else if (e.key === "ArrowUp") {
        next = { x: center.x, y: clamp(center.y - step) };
      } else if (e.key === "ArrowDown") {
        next = { x: center.x, y: clamp(center.y + step) };
      } else {
        return;
      }
      e.preventDefault();
      ctx.setCenter(next);
      announce(
        `x ${Math.round(next.x * 100)}%, y ${Math.round(next.y * 100)}%`,
      );
    };

    return (
      <div
        ref={padRef}
        data-slot="gradient-position-pad"
        onPointerDown={onPointerDown}
        onKeyDown={onKeyDown}
        role="application"
        aria-label={`Gradient position, x ${Math.round(center.x * 100)}%, y ${Math.round(center.y * 100)}%`}
        aria-roledescription="2D pad for gradient center"
        tabIndex={0}
        style={{ width: size, height: size }}
        className={cn(
          "relative shrink-0 cursor-crosshair touch-none rounded-md border border-border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring",
          className,
        )}
        {...rest}
      >
        <span aria-live="polite" className="sr-only">
          {liveText}
        </span>
        <div
          aria-hidden
          className="absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-background bg-foreground"
          style={{ left: `${center.x * 100}%`, top: `${center.y * 100}%` }}
        />
      </div>
    );
  },
);
registry/new-york/ui/fill-picker/parts/gradient/position-input.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
  FieldDivider,
  FieldDraftInput,
  FieldInputGroup,
  FieldShell,
  FieldSuffix,
} from "../field";

export const PositionInput = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function PositionInput({ className, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  if (ctx.gradient.type === "linear") return null;
  const center = ctx.gradient.center;

  const commit = (axis: "x" | "y", raw: string) => {
    if (raw === "") return;
    const n = parseFloat(raw);
    if (!Number.isFinite(n)) return;
    const clamp = (v: number) => Math.max(0, Math.min(1, v / 100));
    ctx.setCenter(
      axis === "x"
        ? { x: clamp(n), y: center.y }
        : { x: center.x, y: clamp(n) },
    );
  };

  return (
    <FieldShell
      ref={ref}
      data-slot="gradient-position-input"
      className={cn("min-w-0 flex-1", className)}
      {...rest}
    >
      <FieldInputGroup>
        <span className="sr-only">Gradient center x</span>
        <FieldDraftInput
          inputMode="decimal"
          nudge={1}
          value={String(Math.round(center.x * 100))}
          onCommit={(raw) => commit("x", raw)}
          aria-label="Gradient center x percent"
          className="w-10"
        />
        <FieldSuffix>%</FieldSuffix>
      </FieldInputGroup>
      <FieldDivider />
      <FieldInputGroup>
        <span className="sr-only">Gradient center y</span>
        <FieldDraftInput
          inputMode="decimal"
          nudge={1}
          value={String(Math.round(center.y * 100))}
          onCommit={(raw) => commit("y", raw)}
          aria-label="Gradient center y percent"
          className="w-10"
        />
        <FieldSuffix>%</FieldSuffix>
      </FieldInputGroup>
    </FieldShell>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/position-group.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";

export const PositionGroup = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function PositionGroup({ className, children, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  if (ctx.gradient.type === "linear") return null;
  return (
    <div
      ref={ref}
      data-slot="gradient-position-group"
      className={cn("flex w-full items-center gap-2", className)}
      {...rest}
    >
      {children}
    </div>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/angle-pad.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { trackPointerDrag } from "./pointer-drag";

export interface AnglePadProps extends React.HTMLAttributes<HTMLDivElement> {
  size?: number;
}

export const AnglePad = React.forwardRef<HTMLDivElement, AnglePadProps>(
  function AnglePad({ className, size = 32, ...rest }, ref) {
    const ctx = useGradientPickerContext();
    const padRef = React.useRef<HTMLDivElement | null>(null);
    React.useImperativeHandle(ref, () => padRef.current as HTMLDivElement);

    if (ctx.gradient.type === "radial") return null;

    const angle =
      ctx.gradient.type === "linear"
        ? ctx.gradient.angle
        : ctx.gradient.startAngle;
    const setAngle =
      ctx.gradient.type === "linear" ? ctx.setAngle : ctx.setStartAngle;

    const fromEvent = (clientX: number, clientY: number): number => {
      const el = padRef.current;
      if (!el) return angle;
      const r = el.getBoundingClientRect();
      const cx = r.left + r.width / 2;
      const cy = r.top + r.height / 2;
      const dx = clientX - cx;
      const dy = clientY - cy;
      const deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
      return (deg + 360) % 360;
    };

    const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
      setAngle(fromEvent(e.clientX, e.clientY));
      trackPointerDrag(e.currentTarget, e.pointerId, (ev) =>
        setAngle(fromEvent(ev.clientX, ev.clientY)),
      );
    };

    const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
      const step = e.shiftKey ? 15 : 1;
      let next = angle;
      if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = angle - step;
      else if (e.key === "ArrowRight" || e.key === "ArrowUp") next = angle + step;
      else if (e.key === "Home") next = 0;
      else if (e.key === "End") next = 360;
      else return;
      e.preventDefault();
      setAngle(((next % 360) + 360) % 360);
    };

    return (
      <div
        ref={padRef}
        onPointerDown={onPointerDown}
        onKeyDown={onKeyDown}
        role="slider"
        aria-label="Gradient angle"
        aria-valuemin={0}
        aria-valuemax={360}
        aria-valuenow={Math.round(angle)}
        aria-valuetext={`${Math.round(angle)} degrees`}
        tabIndex={0}
        data-slot="gradient-angle-pad"
        className={cn(
          "relative shrink-0 cursor-grab touch-none rounded-full border border-border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring",
          className,
        )}
        style={{ width: size, height: size }}
        {...rest}
      >
        <div
          aria-hidden
          className="absolute left-1/2 top-1/2 size-0 origin-center"
          style={{ transform: `translate(-50%, -50%) rotate(${angle}deg)` }}
        >
          <div
            className="absolute left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-foreground shadow-sm ring-1 ring-background/40"
            style={{ top: `calc(-${size / 2}px + 4px)` }}
          />
        </div>
      </div>
    );
  },
);
registry/new-york/ui/fill-picker/parts/gradient/angle-input.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
  FieldDraftInput,
  FieldInputGroup,
  FieldShell,
  FieldSuffix,
} from "../field";

export const AngleInput = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function AngleInput({ className, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  if (ctx.gradient.type === "radial") return null;

  const angle =
    ctx.gradient.type === "linear"
      ? ctx.gradient.angle
      : ctx.gradient.startAngle;
  const setAngle =
    ctx.gradient.type === "linear" ? ctx.setAngle : ctx.setStartAngle;

  const commit = (raw: string) => {
    const n = parseFloat(raw);
    if (Number.isFinite(n)) setAngle(((n % 360) + 360) % 360);
  };

  return (
    <FieldShell
      ref={ref}
      data-slot="gradient-angle-input"
      className={cn("min-w-0 flex-1", className)}
      {...rest}
    >
      <FieldInputGroup>
        <span className="sr-only">Gradient angle</span>
        <FieldDraftInput
          inputMode="decimal"
          nudge={1}
          value={String(Math.round(angle))}
          onCommit={commit}
          aria-label="Gradient angle in degrees"
          className="w-10"
        />
        <FieldSuffix>°</FieldSuffix>
      </FieldInputGroup>
    </FieldShell>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/angle-group.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";

export const AngleGroup = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function AngleGroup({ className, children, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  if (ctx.gradient.type === "radial") return null;
  return (
    <div
      ref={ref}
      data-slot="gradient-angle-group"
      className={cn("flex w-full items-center gap-2", className)}
      {...rest}
    >
      {children}
    </div>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/shape-switcher.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";

const OPTIONS = [
  { value: "circle", label: "Circle" },
  { value: "ellipse", label: "Ellipse" },
] as const;

export const ShapeSwitcher = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function ShapeSwitcher({ className, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  if (ctx.gradient.type !== "radial") return null;
  const current = ctx.gradient.shape;

  // role="group" + aria-pressed buttons rather than role="tablist" / "tab":
  // the WAI-ARIA tab pattern requires arrow-key navigation and tabpanels,
  // neither of which apply here. This is a segmented toggle, and shadcn's
  // own Toggle uses aria-pressed for the same reason. Each button is
  // independently Tab-reachable; activation is via Space/Enter (native
  // <button> behavior).
  return (
    <div
      ref={ref}
      role="group"
      aria-label="Radial gradient shape"
      data-slot="gradient-shape-switcher"
      className={cn(
        "inline-flex w-full rounded-md bg-muted p-0.75 gap-0.5",
        className,
      )}
      {...rest}
    >
      {OPTIONS.map(({ value, label }) => {
        const active = current === value;
        return (
          <button
            key={value}
            type="button"
            aria-pressed={active}
            onClick={() => ctx.setRadialShape(value)}
            className={cn(
              "flex-1 rounded-sm px-3 py-1 text-xs font-medium outline-none transition-colors",
              "focus-visible:ring-2 focus-visible:ring-ring",
              active
                ? "bg-background text-foreground shadow-sm"
                : "text-muted-foreground hover:text-foreground",
            )}
          >
            {label}
          </button>
        );
      })}
    </div>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/radius-input.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
  FieldDraftInput,
  FieldInputGroup,
  FieldShell,
  FieldSuffix,
} from "../field";

export const RadiusInput = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function RadiusInput({ className, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  if (ctx.gradient.type !== "radial") return null;
  if (ctx.gradient.shape !== "circle") return null;
  const g = ctx.gradient;
  const usePercent = !!ctx.containerWidth;
  const display = (() => {
    if (g.radiusPx === undefined) return "";
    return String(
      usePercent
        ? Math.round((g.radiusPx / (ctx.containerWidth as number)) * 100)
        : Math.round(g.radiusPx),
    );
  })();

  const commit = (raw: string) => {
    const v = raw.trim();
    if (v === "") {
      ctx.setRadiusPx(undefined);
      return;
    }
    const n = parseFloat(v);
    if (!Number.isFinite(n)) return;
    if (!usePercent) {
      ctx.setRadiusPx(Math.max(0, n));
      return;
    }
    ctx.setRadiusPx(Math.max(0, (n / 100) * (ctx.containerWidth as number)));
  };

  return (
    <FieldShell
      ref={ref}
      data-slot="gradient-radius-input"
      className={cn("min-w-0 shrink-0", className)}
      {...rest}
    >
      <FieldInputGroup>
        <span className="sr-only">Circle radius</span>
        <FieldDraftInput
          inputMode="decimal"
          nudge={1}
          value={display}
          placeholder="auto"
          onCommit={commit}
          aria-label={
            usePercent
              ? "Circle radius as percent of Area width"
              : "Circle radius in pixels"
          }
          className="w-12"
        />
        <FieldSuffix>{usePercent ? "%" : "px"}</FieldSuffix>
      </FieldInputGroup>
    </FieldShell>
  );
});
registry/new-york/ui/fill-picker/parts/gradient/ellipse-radii-input.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
  FieldDivider,
  FieldDraftInput,
  FieldInputGroup,
  FieldShell,
  FieldSuffix,
} from "../field";
import { keywordToRadii } from "./overlay";

export const EllipseRadiiInput = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function EllipseRadiiInput({ className, ...rest }, ref) {
  const ctx = useGradientPickerContext();
  if (ctx.gradient.type !== "radial") return null;
  if (ctx.gradient.shape !== "ellipse") return null;
  const g = ctx.gradient;

  const commit = (axis: "x" | "y", raw: string) => {
    if (raw.trim() === "") {
      ctx.setRadii(undefined);
      return;
    }
    const n = parseFloat(raw);
    if (!Number.isFinite(n)) return;
    // Keep the untouched axis at its *effective* extent. For an ellipse the
    // keyword seed is already normalized per axis, so the box size cancels
    // out (1 × 1) — the same seed the Overlay's edge handle starts from.
    const current =
      g.radii ?? keywordToRadii("ellipse", g.size, g.center, 1, 1);
    ctx.setRadii(
      axis === "x"
        ? { x: Math.max(0, n / 100), y: current.y }
        : { x: current.x, y: Math.max(0, n / 100) },
    );
  };

  return (
    <FieldShell
      ref={ref}
      data-slot="gradient-ellipse-radii-input"
      className={cn("min-w-0 shrink-0", className)}
      {...rest}
    >
      <FieldInputGroup>
        <span className="sr-only">Ellipse horizontal radius</span>
        <FieldDraftInput
          inputMode="decimal"
          nudge={1}
          value={g.radii ? String(Math.round(g.radii.x * 100)) : ""}
          placeholder="auto"
          onCommit={(raw) => commit("x", raw)}
          aria-label="Ellipse horizontal radius percent"
          className="w-12"
        />
      </FieldInputGroup>
      <FieldDivider />
      <FieldInputGroup>
        <span className="sr-only">Ellipse vertical radius</span>
        <FieldDraftInput
          inputMode="decimal"
          nudge={1}
          value={g.radii ? String(Math.round(g.radii.y * 100)) : ""}
          placeholder="auto"
          onCommit={(raw) => commit("y", raw)}
          aria-label="Ellipse vertical radius percent"
          className="w-12"
        />
        <FieldSuffix>%</FieldSuffix>
      </FieldInputGroup>
    </FieldShell>
  );
});

More from amplo

Browse all components in this library →