{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "palette-editor",
  "title": "配色编辑器",
  "description": "编辑有序配色，提供色觉模拟与可区分性提示。",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "https://yiui.dev/r/color-picker.json",
    "https://yiui.dev/r/color.json",
    "https://yiui.dev/r/color-vision.json",
    "https://yiui.dev/r/segmented-control.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/palette-editor/palette-editor.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUp, ArrowDown, Trash2, Plus, TriangleAlert } from \"lucide-react\"\nimport { Button } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { ColorPicker } from \"@/components/ui/color-picker/color-picker\"\nimport { SegmentedControl } from \"@/components/ui/segmented-control/segmented-control\"\nimport {\n  deltaE2000,\n  hexToLab,\n  findColorConflicts,\n  simulateColorVision,\n  type ColorConflict,\n  type ColorVision,\n} from \"@/lib/color-vision\"\nimport {\n  hslToRgb,\n  hsvToRgb,\n  parseHex,\n  rgbToHex,\n  rgbToHsv,\n} from \"@/lib/color\"\n\nconst defaultLabels = {\n  normal: \"正常\",\n  protanopia: \"红色盲\",\n  deuteranopia: \"绿色盲\",\n  tritanopia: \"蓝色盲\",\n  vision: \"色觉视角\",\n  add: \"添加颜色\",\n  up: (n: number) => `上移第 ${n} 个颜色`,\n  down: (n: number) => `下移第 ${n} 个颜色`,\n  remove: (n: number) => `删除第 ${n} 个颜色`,\n  openColor: (n: number) => `选择第 ${n} 个颜色`,\n  warning: \"难以分清\",\n  moreConflicts: (n: number) => `另有 ${n} 条同样难以分清`,\n  presets: \"配色预设\",\n  color: (n: number) => `颜色 ${n}`,\n  moved: (n: number, m: number, k: number) =>\n    `第 ${n} 项已移到第 ${m} 项，共 ${k} 项`,\n  preview: \"当前视角预览\",\n  conflict: (a: number, b: number, vision: string, d: number) =>\n    `第 ${a}、${b} 项在${vision}视角下难以分清（ΔE ${d.toFixed(1)}）`,\n}\ntype RowLabel = string | ((n: number) => string)\ntype PaletteEditorLabels = Omit<\n  typeof defaultLabels,\n  \"up\" | \"down\" | \"remove\" | \"openColor\"\n> & {\n  up: RowLabel\n  down: RowLabel\n  remove: RowLabel\n  openColor?: RowLabel\n}\nfunction rowLabel(label: RowLabel, n: number) {\n  return typeof label === \"function\" ? label(n) : label\n}\ntype PaletteEditorProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> & {\n  /** 受控 HEX 数组 */ value?: string[]\n  /** 初始 HEX 数组 @default [\"#2563eb\"] */ defaultValue?: string[]\n  /** 编辑时触发 */ onValueChange?: (value: string[]) => void\n  /** 编辑结束时触发 */ onValueCommit?: (value: string[]) => void\n  /** 最少项数 @default 1 */ minItems?: number\n  /** 最多项数 @default 12 */ maxItems?: number\n  /** 冲突提示数量上限，Infinity 显示全部 @default 4 */ maxConflicts?: number\n  /** ΔE00 提示阈值 @default 10 */ threshold?: number\n  /** 自定义冲突检查；传入后替代内置检查，threshold 不再生效 */ findConflicts?: (\n    colors: string[],\n  ) => ColorConflict[]\n  /** 选择时复制颜色 */ presets?: { label: string; colors: string[] }[]\n  /** 中文文案覆盖 */ labels?: Partial<PaletteEditorLabels>\n  /** 禁用 @default false */ disabled?: boolean\n  /** 只读 @default false */ readOnly?: boolean\n  /** JSON 数组字段名 */ name?: string\n  /** 所属表单 */ form?: string\n}\nconst initial = [\"#2563eb\"]\n/** 在末项基础上旋转黄金角，保留鲜明的色相变化。 */\nfunction nextColor(last: string | undefined) {\n  const rgb = parseHex(last ?? \"\") ?? parseHex(initial.at(0) ?? \"\")!\n  const hsv = rgbToHsv(rgb)\n  return rgbToHex(\n    hsvToRgb({\n      ...hsv,\n      h: (hsv.h + 137.5) % 360,\n      s: Math.max(hsv.s, 0.45),\n      v: Math.max(hsv.v, 0.55),\n    }),\n  )\n}\n// 固定候选只生成一次；HSL 数值是搜索空间，不是界面样式。\nconst colorCandidates = Array.from({ length: 24 }, (_, h) =>\n  [0.55, 0.9].flatMap((s) =>\n    [0.25, 0.5, 0.75].map((l) => rgbToHex(hslToRgb({ h: h * 15, s, l, a: 1 }))),\n  ),\n).flat()\n\nfunction colorDistance(a: string, b: string) {\n  // 直接走模拟后的浮点色差，不能用取整后的模拟 HEX。\n  return Math.min(\n    ...findColorConflicts([a, b], { threshold: Infinity }).map((c) => c.deltaE),\n  )\n}\n\nfunction pickNextColor(colors: string[], threshold: number) {\n  const golden = nextColor(colors.at(-1))\n  if (colors.every((color) => colorDistance(golden, color) >= threshold))\n    return golden\n  let best = golden,\n    score = -Infinity,\n    tie = Infinity\n  const target = hexToLab(golden)\n  for (const candidate of [...colorCandidates, golden]) {\n    let minimum = Infinity\n    for (const color of colors) {\n      minimum = Math.min(minimum, colorDistance(candidate, color))\n      // 后续比较只能降低最小值，保留容差内的候选参与平分。\n      if (minimum < score - 1e-10) break\n    }\n    const proximity = deltaE2000(hexToLab(candidate), target)\n    if (\n      minimum > score + 1e-10 ||\n      (Math.abs(minimum - score) <= 1e-10 && proximity < tie)\n    ) {\n      best = candidate\n      score = minimum\n      tie = proximity\n    }\n  }\n  return best\n}\n\nfunction PaletteEditor({\n  value: prop,\n  defaultValue = initial,\n  onValueChange,\n  onValueCommit,\n  minItems = 1,\n  maxItems = 12,\n  threshold = 10,\n  maxConflicts = 4,\n  findConflicts,\n  presets = [],\n  labels: overrides,\n  disabled,\n  readOnly,\n  name,\n  form,\n  className,\n  ...props\n}: PaletteEditorProps) {\n  const labels = { ...defaultLabels, ...overrides }\n  const baseId = React.useId()\n  const [value, setValue] = useControllableState({\n    prop,\n    defaultProp: defaultValue,\n    onChange: onValueChange,\n  })\n  const [vision, setVision] = React.useState<ColorVision>(\"normal\")\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const root = React.useRef<HTMLDivElement>(null)\n  const [identity, setIdentity] = React.useState(() => ({\n    keys: value.map((_, i) => `color-${i}`),\n    next: value.length,\n  }))\n  let keys = identity.keys\n  if (keys.length !== value.length) {\n    keys = keys.slice(0, value.length)\n    let next = identity.next\n    while (keys.length < value.length) keys.push(`color-${next++}`)\n    setIdentity({ keys, next })\n  }\n  const locked = disabled || readOnly\n  const lockedRef = React.useRef(locked)\n  React.useLayoutEffect(() => {\n    lockedRef.current = locked\n  }, [locked])\n  const conflicts = React.useMemo(\n    () =>\n      findConflicts\n        ? findConflicts(value)\n        : findColorConflicts(value, { threshold }),\n    [value, threshold, findConflicts],\n  )\n  // 只排序副本，保留调用方数组以及全部冲突供行标记使用；同色差保持原顺序。\n  const visibleConflicts = React.useMemo(\n    () =>\n      [...conflicts]\n        .sort((a, b) => a.deltaE - b.deltaE)\n        .slice(0, Math.max(0, Math.floor(maxConflicts))),\n    [conflicts, maxConflicts],\n  )\n  const remainingConflicts = conflicts.length - visibleConflicts.length\n  function change(next: string[], commit = true) {\n    if (lockedRef.current) return\n    setValue(next)\n    if (commit) onValueCommit?.([...next])\n  }\n  function move(i: number, to: number) {\n    if (locked || to < 0 || to >= value.length) return\n    const focused = document.activeElement as HTMLElement | null\n    const next = [...value],\n      nextKeys = [...keys]\n    next.splice(to, 0, ...next.splice(i, 1))\n    nextKeys.splice(to, 0, ...nextKeys.splice(i, 1))\n    setIdentity({ ...identity, keys: nextKeys })\n    change(next)\n    setAnnouncement(labels.moved(i + 1, to + 1, value.length))\n    requestAnimationFrame(() => {\n      if (focused?.matches(\":disabled\")) {\n        root.current\n          ?.querySelectorAll<HTMLElement>(\"[data-slot=palette-editor-item]\")\n          [to]?.focus()\n      } else if (focused && root.current?.contains(focused)) focused.focus()\n    })\n  }\n  React.useEffect(() => {\n    const el = form\n      ? document.getElementById(form)\n      : root.current?.closest(\"form\")\n    const reset = () => {\n      if (prop === undefined)\n        requestAnimationFrame(() => setValue([...defaultValue]))\n    }\n    el?.addEventListener(\"reset\", reset)\n    return () => el?.removeEventListener(\"reset\", reset)\n  }, [form, prop, defaultValue, setValue])\n  return (\n    <div\n      {...props}\n      ref={root}\n      data-slot=\"palette-editor\"\n      className={cn(\"w-full min-w-0 space-y-3\", className)}\n    >\n      <SegmentedControl\n        aria-label={labels.vision}\n        size=\"sm\"\n        value={vision}\n        onValueChange={(v) => setVision(v as ColorVision)}\n        disabled={disabled}\n        options={(\n          [\"normal\", \"protanopia\", \"deuteranopia\", \"tritanopia\"] as const\n        ).map((v) => ({ value: v, label: labels[v] }))}\n      />\n      {/* 当前视角下整组颜色并排显示，切换视角时直接对比；HEX 始终是原色 */}\n      <div\n        data-slot=\"palette-editor-preview\"\n        role=\"img\"\n        aria-label={`${labels.preview}：${labels[vision]}`}\n        className=\"flex h-8 overflow-hidden rounded-md ring-1 ring-foreground/10 ring-inset\"\n      >\n        {value.map((hex, i) => (\n          <span\n            key={keys[i]}\n            data-slot=\"palette-editor-swatch\"\n            className=\"flex-1\"\n            style={{\n              backgroundColor:\n                vision === \"normal\" ? hex : simulateColorVision(hex, vision),\n            }}\n          />\n        ))}\n      </div>\n      {presets.length > 0 && (\n        <div\n          role=\"group\"\n          aria-label={labels.presets}\n          className=\"flex flex-wrap gap-2\"\n        >\n          {presets.map((p) => (\n            <Button\n              type=\"button\"\n              size=\"sm\"\n              variant=\"outline\"\n              key={p.label}\n              disabled={\n                locked ||\n                p.colors.length < minItems ||\n                p.colors.length > maxItems\n              }\n              onClick={() => change([...p.colors])}\n            >\n              {p.label}\n            </Button>\n          ))}\n        </div>\n      )}\n      <ol data-slot=\"palette-editor-list\" className=\"space-y-2\">\n        {value.map((hex, i) => {\n          const warning = conflicts.some((c) => c.a === i || c.b === i)\n          return (\n            <li\n              key={keys[i]}\n              data-slot=\"palette-editor-item\"\n              data-conflict={warning || undefined}\n              tabIndex={-1}\n              className=\"rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50\"\n              onKeyDown={(e) => {\n                if (\n                  !e.altKey ||\n                  ![\"ArrowUp\", \"ArrowDown\"].includes(e.key) ||\n                  !(e.target instanceof HTMLElement) ||\n                  e.target.closest(\"input,[role=dialog]\")\n                )\n                  return\n                if (!e.currentTarget.contains(e.target)) return\n                e.preventDefault()\n                move(i, i + (e.key === \"ArrowUp\" ? -1 : 1))\n              }}\n            >\n              {/* 放不下时操作按钮换到第二行，保证 HEX 完整显示 */}\n              <div className=\"flex min-w-0 flex-wrap items-center gap-1\">\n                {/* 序号就是输入框的可见标签；读屏读完整名称「颜色 N」 */}\n                <label\n                  htmlFor={`${baseId}-${keys[i]}`}\n                  className=\"w-5 shrink-0 text-center text-xs text-muted-foreground\"\n                >\n                  <span className=\"sr-only\">{labels.color(i + 1)}</span>\n                  <span aria-hidden>{i + 1}</span>\n                </label>\n                {/* 固定宽度的冲突标记位，出现与消失不挤动整行 */}\n                <span className=\"flex w-4 shrink-0 justify-center text-muted-foreground\">\n                  {warning && (\n                    <>\n                      <TriangleAlert className=\"size-3.5\" aria-hidden />\n                      <span className=\"sr-only\">{labels.warning}</span>\n                    </>\n                  )}\n                </span>\n                <ColorPicker\n                  labels={{\n                    open: rowLabel(\n                      labels.openColor ?? defaultLabels.openColor,\n                      i + 1,\n                    ),\n                  }}\n                  size=\"sm\"\n                  id={`${baseId}-${keys[i]}`}\n                  value={hex}\n                  disabled={disabled}\n                  readOnly={readOnly}\n                  className=\"min-w-36 flex-1 basis-0\"\n                  onValueChange={(color) =>\n                    change(\n                      value.map((v, n) => (n === i ? color : v)),\n                      false,\n                    )\n                  }\n                  onValueCommit={(color) =>\n                    !lockedRef.current &&\n                    onValueCommit?.(value.map((v, n) => (n === i ? color : v)))\n                  }\n                />\n                <div className=\"ml-auto flex items-center gap-1\">\n                  <Button\n                    type=\"button\"\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                    aria-label={rowLabel(labels.up, i + 1)}\n                    disabled={locked || i === 0}\n                    onClick={() => move(i, i - 1)}\n                  >\n                    <ArrowUp />\n                  </Button>\n                  <Button\n                    type=\"button\"\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                    aria-label={rowLabel(labels.down, i + 1)}\n                    disabled={locked || i === value.length - 1}\n                    onClick={() => move(i, i + 1)}\n                  >\n                    <ArrowDown />\n                  </Button>\n                  <Button\n                    type=\"button\"\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                    aria-label={rowLabel(labels.remove, i + 1)}\n                    disabled={locked || value.length <= minItems}\n                    onClick={() => {\n                      setIdentity({\n                        ...identity,\n                        keys: keys.filter((_, n) => n !== i),\n                      })\n                      change(value.filter((_, n) => n !== i))\n                      requestAnimationFrame(() =>\n                        root.current\n                          ?.querySelectorAll<HTMLElement>(\n                            \"[data-slot=palette-editor-item]\",\n                          )\n                          [Math.min(i, value.length - 2)]?.focus(),\n                      )\n                    }}\n                  >\n                    <Trash2 />\n                  </Button>\n                </div>\n              </div>\n            </li>\n          )\n        })}\n      </ol>\n      <Button\n        type=\"button\"\n        size=\"sm\"\n        variant=\"outline\"\n        disabled={locked || value.length >= maxItems}\n        onClick={() =>\n          change([\n            ...value,\n            findConflicts\n              ? nextColor(value.at(-1))\n              : pickNextColor(value, threshold),\n          ])\n        }\n      >\n        <Plus />\n        {labels.add}\n      </Button>\n      {conflicts.length > 0 && (\n        <ul\n          data-slot=\"palette-editor-conflicts\"\n          className=\"space-y-1 border-t border-edge pt-3 text-xs text-muted-foreground\"\n        >\n          {visibleConflicts.map((c) => (\n            <li\n              key={`${c.a}-${c.b}-${c.vision}`}\n              className=\"flex items-start gap-1.5\"\n            >\n              <TriangleAlert className=\"mt-px size-3.5 shrink-0\" aria-hidden />\n              {labels.conflict(c.a + 1, c.b + 1, labels[c.vision], c.deltaE)}\n            </li>\n          ))}\n          {remainingConflicts > 0 && (\n            <li className=\"flex items-start gap-1.5\">\n              <span className=\"size-3.5 shrink-0\" aria-hidden />\n              {labels.moreConflicts(remainingConflicts)}\n            </li>\n          )}\n        </ul>\n      )}\n      <p\n        data-slot=\"palette-editor-announcement\"\n        aria-live=\"polite\"\n        aria-atomic=\"true\"\n        className=\"sr-only\"\n      >\n        {announcement}\n      </p>\n      {name && (\n        <input\n          type=\"hidden\"\n          name={name}\n          form={form}\n          value={JSON.stringify(value)}\n          disabled={disabled}\n        />\n      )}\n    </div>\n  )\n}\nexport { PaletteEditor }\nexport type { PaletteEditorProps, PaletteEditorLabels }\n",
      "type": "registry:ui",
      "target": "@ui/palette-editor/palette-editor.tsx"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
