@orz/ui 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +541 -2
  3. package/dist/components/avatar.d.ts +14 -0
  4. package/dist/components/avatar.d.ts.map +1 -0
  5. package/dist/components/avatar.js +23 -0
  6. package/dist/components/avatar.js.map +1 -0
  7. package/dist/components/badge.d.ts +10 -0
  8. package/dist/components/badge.d.ts.map +1 -0
  9. package/dist/components/badge.js +26 -0
  10. package/dist/components/badge.js.map +1 -0
  11. package/dist/components/button.d.ts +11 -0
  12. package/dist/components/button.d.ts.map +1 -0
  13. package/dist/components/button.js +40 -0
  14. package/dist/components/button.js.map +1 -0
  15. package/dist/components/card.d.ts +12 -0
  16. package/dist/components/card.d.ts.map +1 -0
  17. package/dist/components/card.js +25 -0
  18. package/dist/components/card.js.map +1 -0
  19. package/dist/components/color-picker.d.ts +58 -0
  20. package/dist/components/color-picker.d.ts.map +1 -0
  21. package/dist/components/color-picker.js +950 -0
  22. package/dist/components/color-picker.js.map +1 -0
  23. package/dist/components/confirm-dialog.d.ts +26 -0
  24. package/dist/components/confirm-dialog.d.ts.map +1 -0
  25. package/dist/components/confirm-dialog.js +18 -0
  26. package/dist/components/confirm-dialog.js.map +1 -0
  27. package/dist/components/dialog.d.ts +26 -0
  28. package/dist/components/dialog.d.ts.map +1 -0
  29. package/dist/components/dialog.js +149 -0
  30. package/dist/components/dialog.js.map +1 -0
  31. package/dist/components/dropdown-menu.d.ts +30 -0
  32. package/dist/components/dropdown-menu.d.ts.map +1 -0
  33. package/dist/components/dropdown-menu.js +100 -0
  34. package/dist/components/dropdown-menu.js.map +1 -0
  35. package/dist/components/filter-menu.d.ts +98 -0
  36. package/dist/components/filter-menu.d.ts.map +1 -0
  37. package/dist/components/filter-menu.js +745 -0
  38. package/dist/components/filter-menu.js.map +1 -0
  39. package/dist/components/highlighted-input.d.ts +40 -0
  40. package/dist/components/highlighted-input.d.ts.map +1 -0
  41. package/dist/components/highlighted-input.js +95 -0
  42. package/dist/components/highlighted-input.js.map +1 -0
  43. package/dist/components/hover-card.d.ts +7 -0
  44. package/dist/components/hover-card.d.ts.map +1 -0
  45. package/dist/components/hover-card.js +22 -0
  46. package/dist/components/hover-card.js.map +1 -0
  47. package/dist/components/input.d.ts +4 -0
  48. package/dist/components/input.d.ts.map +1 -0
  49. package/dist/components/input.js +7 -0
  50. package/dist/components/input.js.map +1 -0
  51. package/dist/components/label.d.ts +5 -0
  52. package/dist/components/label.d.ts.map +1 -0
  53. package/dist/components/label.js +8 -0
  54. package/dist/components/label.js.map +1 -0
  55. package/dist/components/link-chip.d.ts +67 -0
  56. package/dist/components/link-chip.d.ts.map +1 -0
  57. package/dist/components/link-chip.js +71 -0
  58. package/dist/components/link-chip.js.map +1 -0
  59. package/dist/components/load-failure.d.ts +24 -0
  60. package/dist/components/load-failure.d.ts.map +1 -0
  61. package/dist/components/load-failure.js +27 -0
  62. package/dist/components/load-failure.js.map +1 -0
  63. package/dist/components/load-more.d.ts +15 -0
  64. package/dist/components/load-more.d.ts.map +1 -0
  65. package/dist/components/load-more.js +25 -0
  66. package/dist/components/load-more.js.map +1 -0
  67. package/dist/components/media-controls.d.ts +50 -0
  68. package/dist/components/media-controls.d.ts.map +1 -0
  69. package/dist/components/media-controls.js +71 -0
  70. package/dist/components/media-controls.js.map +1 -0
  71. package/dist/components/popover.d.ts +9 -0
  72. package/dist/components/popover.d.ts.map +1 -0
  73. package/dist/components/popover.js +82 -0
  74. package/dist/components/popover.js.map +1 -0
  75. package/dist/components/query-box.d.ts +78 -0
  76. package/dist/components/query-box.d.ts.map +1 -0
  77. package/dist/components/query-box.js +332 -0
  78. package/dist/components/query-box.js.map +1 -0
  79. package/dist/components/query-highlight.d.ts +14 -0
  80. package/dist/components/query-highlight.d.ts.map +1 -0
  81. package/dist/components/query-highlight.js +132 -0
  82. package/dist/components/query-highlight.js.map +1 -0
  83. package/dist/components/select.d.ts +19 -0
  84. package/dist/components/select.d.ts.map +1 -0
  85. package/dist/components/select.js +38 -0
  86. package/dist/components/select.js.map +1 -0
  87. package/dist/components/separator.d.ts +5 -0
  88. package/dist/components/separator.d.ts.map +1 -0
  89. package/dist/components/separator.js +9 -0
  90. package/dist/components/separator.js.map +1 -0
  91. package/dist/components/sheet.d.ts +16 -0
  92. package/dist/components/sheet.d.ts.map +1 -0
  93. package/dist/components/sheet.js +41 -0
  94. package/dist/components/sheet.js.map +1 -0
  95. package/dist/components/skeleton.d.ts +3 -0
  96. package/dist/components/skeleton.d.ts.map +1 -0
  97. package/dist/components/skeleton.js +7 -0
  98. package/dist/components/skeleton.js.map +1 -0
  99. package/dist/components/slider.d.ts +8 -0
  100. package/dist/components/slider.d.ts.map +1 -0
  101. package/dist/components/slider.js +14 -0
  102. package/dist/components/slider.js.map +1 -0
  103. package/dist/components/sonner.d.ts +4 -0
  104. package/dist/components/sonner.d.ts.map +1 -0
  105. package/dist/components/sonner.js +26 -0
  106. package/dist/components/sonner.js.map +1 -0
  107. package/dist/components/switch.d.ts +5 -0
  108. package/dist/components/switch.d.ts.map +1 -0
  109. package/dist/components/switch.js +8 -0
  110. package/dist/components/switch.js.map +1 -0
  111. package/dist/components/table.d.ts +11 -0
  112. package/dist/components/table.d.ts.map +1 -0
  113. package/dist/components/table.js +28 -0
  114. package/dist/components/table.js.map +1 -0
  115. package/dist/components/tabs.d.ts +8 -0
  116. package/dist/components/tabs.d.ts.map +1 -0
  117. package/dist/components/tabs.js +18 -0
  118. package/dist/components/tabs.js.map +1 -0
  119. package/dist/components/textarea.d.ts +4 -0
  120. package/dist/components/textarea.d.ts.map +1 -0
  121. package/dist/components/textarea.js +7 -0
  122. package/dist/components/textarea.js.map +1 -0
  123. package/dist/components/theme-editor/draft.d.ts +235 -0
  124. package/dist/components/theme-editor/draft.d.ts.map +1 -0
  125. package/dist/components/theme-editor/draft.js +638 -0
  126. package/dist/components/theme-editor/draft.js.map +1 -0
  127. package/dist/components/theme-editor/export-panel.d.ts +14 -0
  128. package/dist/components/theme-editor/export-panel.d.ts.map +1 -0
  129. package/dist/components/theme-editor/export-panel.js +39 -0
  130. package/dist/components/theme-editor/export-panel.js.map +1 -0
  131. package/dist/components/theme-editor/form.d.ts +69 -0
  132. package/dist/components/theme-editor/form.d.ts.map +1 -0
  133. package/dist/components/theme-editor/form.js +350 -0
  134. package/dist/components/theme-editor/form.js.map +1 -0
  135. package/dist/components/theme-editor/import-panel.d.ts +21 -0
  136. package/dist/components/theme-editor/import-panel.d.ts.map +1 -0
  137. package/dist/components/theme-editor/import-panel.js +117 -0
  138. package/dist/components/theme-editor/import-panel.js.map +1 -0
  139. package/dist/components/theme-editor/theme-list.d.ts +23 -0
  140. package/dist/components/theme-editor/theme-list.d.ts.map +1 -0
  141. package/dist/components/theme-editor/theme-list.js +42 -0
  142. package/dist/components/theme-editor/theme-list.js.map +1 -0
  143. package/dist/components/theme-editor/token-list.d.ts +30 -0
  144. package/dist/components/theme-editor/token-list.d.ts.map +1 -0
  145. package/dist/components/theme-editor/token-list.js +124 -0
  146. package/dist/components/theme-editor/token-list.js.map +1 -0
  147. package/dist/components/theme-editor/tokens.d.ts +37 -0
  148. package/dist/components/theme-editor/tokens.d.ts.map +1 -0
  149. package/dist/components/theme-editor/tokens.js +155 -0
  150. package/dist/components/theme-editor/tokens.js.map +1 -0
  151. package/dist/components/theme-editor.d.ts +26 -0
  152. package/dist/components/theme-editor.d.ts.map +1 -0
  153. package/dist/components/theme-editor.js +467 -0
  154. package/dist/components/theme-editor.js.map +1 -0
  155. package/dist/components/theme-menu.d.ts +28 -0
  156. package/dist/components/theme-menu.d.ts.map +1 -0
  157. package/dist/components/theme-menu.js +308 -0
  158. package/dist/components/theme-menu.js.map +1 -0
  159. package/dist/components/tooltip.d.ts +11 -0
  160. package/dist/components/tooltip.d.ts.map +1 -0
  161. package/dist/components/tooltip.js +18 -0
  162. package/dist/components/tooltip.js.map +1 -0
  163. package/dist/components/unsaved-changes-dialog.d.ts +39 -0
  164. package/dist/components/unsaved-changes-dialog.d.ts.map +1 -0
  165. package/dist/components/unsaved-changes-dialog.js +46 -0
  166. package/dist/components/unsaved-changes-dialog.js.map +1 -0
  167. package/dist/hooks/scroll-insets.d.ts +89 -0
  168. package/dist/hooks/scroll-insets.d.ts.map +1 -0
  169. package/dist/hooks/scroll-insets.js +265 -0
  170. package/dist/hooks/scroll-insets.js.map +1 -0
  171. package/dist/hooks/use-element-height.d.ts +11 -0
  172. package/dist/hooks/use-element-height.d.ts.map +1 -0
  173. package/dist/hooks/use-element-height.js +33 -0
  174. package/dist/hooks/use-element-height.js.map +1 -0
  175. package/dist/hooks/use-media-query.d.ts +22 -0
  176. package/dist/hooks/use-media-query.d.ts.map +1 -0
  177. package/dist/hooks/use-media-query.js +38 -0
  178. package/dist/hooks/use-media-query.js.map +1 -0
  179. package/dist/hooks/use-overlay-scrollbars.d.ts +31 -0
  180. package/dist/hooks/use-overlay-scrollbars.d.ts.map +1 -0
  181. package/dist/hooks/use-overlay-scrollbars.js +80 -0
  182. package/dist/hooks/use-overlay-scrollbars.js.map +1 -0
  183. package/dist/hooks/use-paged-append.d.ts +7 -0
  184. package/dist/hooks/use-paged-append.d.ts.map +1 -0
  185. package/dist/hooks/use-paged-append.js +53 -0
  186. package/dist/hooks/use-paged-append.js.map +1 -0
  187. package/dist/hooks/use-query-completion.d.ts +27 -0
  188. package/dist/hooks/use-query-completion.d.ts.map +1 -0
  189. package/dist/hooks/use-query-completion.js +100 -0
  190. package/dist/hooks/use-query-completion.js.map +1 -0
  191. package/dist/hooks/use-slash-shortcut.d.ts +9 -0
  192. package/dist/hooks/use-slash-shortcut.d.ts.map +1 -0
  193. package/dist/hooks/use-slash-shortcut.js +38 -0
  194. package/dist/hooks/use-slash-shortcut.js.map +1 -0
  195. package/dist/hooks/viewport-chrome.d.ts +50 -0
  196. package/dist/hooks/viewport-chrome.d.ts.map +1 -0
  197. package/dist/hooks/viewport-chrome.js +170 -0
  198. package/dist/hooks/viewport-chrome.js.map +1 -0
  199. package/dist/index.d.ts +2 -0
  200. package/dist/index.d.ts.map +1 -0
  201. package/dist/index.js +2 -0
  202. package/dist/index.js.map +1 -0
  203. package/dist/lib/cell-width-table.d.ts +3 -0
  204. package/dist/lib/cell-width-table.d.ts.map +1 -0
  205. package/dist/lib/cell-width-table.js +6 -0
  206. package/dist/lib/cell-width-table.js.map +1 -0
  207. package/dist/lib/cell-width.d.ts +7 -0
  208. package/dist/lib/cell-width.d.ts.map +1 -0
  209. package/dist/lib/cell-width.js +50 -0
  210. package/dist/lib/cell-width.js.map +1 -0
  211. package/dist/lib/clipboard.d.ts +8 -0
  212. package/dist/lib/clipboard.d.ts.map +1 -0
  213. package/dist/lib/clipboard.js +19 -0
  214. package/dist/lib/clipboard.js.map +1 -0
  215. package/dist/lib/color.d.ts +40 -0
  216. package/dist/lib/color.d.ts.map +1 -0
  217. package/dist/lib/color.js +177 -0
  218. package/dist/lib/color.js.map +1 -0
  219. package/dist/lib/scroll-map.d.ts +20 -0
  220. package/dist/lib/scroll-map.d.ts.map +1 -0
  221. package/dist/lib/scroll-map.js +46 -0
  222. package/dist/lib/scroll-map.js.map +1 -0
  223. package/dist/lib/text-metrics.d.ts +33 -0
  224. package/dist/lib/text-metrics.d.ts.map +1 -0
  225. package/dist/lib/text-metrics.js +89 -0
  226. package/dist/lib/text-metrics.js.map +1 -0
  227. package/dist/lib/theme-file.d.ts +51 -0
  228. package/dist/lib/theme-file.d.ts.map +1 -0
  229. package/dist/lib/theme-file.js +511 -0
  230. package/dist/lib/theme-file.js.map +1 -0
  231. package/dist/lib/unsaved-guard.d.ts +33 -0
  232. package/dist/lib/unsaved-guard.d.ts.map +1 -0
  233. package/dist/lib/unsaved-guard.js +37 -0
  234. package/dist/lib/unsaved-guard.js.map +1 -0
  235. package/dist/lib/utils.d.ts +3 -0
  236. package/dist/lib/utils.d.ts.map +1 -0
  237. package/dist/lib/utils.js +6 -0
  238. package/dist/lib/utils.js.map +1 -0
  239. package/dist/theme/custom.d.ts +8 -0
  240. package/dist/theme/custom.d.ts.map +1 -0
  241. package/dist/theme/custom.js +56 -0
  242. package/dist/theme/custom.js.map +1 -0
  243. package/dist/theme/index.d.ts +6 -0
  244. package/dist/theme/index.d.ts.map +1 -0
  245. package/dist/theme/index.js +5 -0
  246. package/dist/theme/index.js.map +1 -0
  247. package/dist/theme/paint-guard.d.ts +20 -0
  248. package/dist/theme/paint-guard.d.ts.map +1 -0
  249. package/dist/theme/paint-guard.js +120 -0
  250. package/dist/theme/paint-guard.js.map +1 -0
  251. package/dist/theme/picks.d.ts +22 -0
  252. package/dist/theme/picks.d.ts.map +1 -0
  253. package/dist/theme/picks.js +43 -0
  254. package/dist/theme/picks.js.map +1 -0
  255. package/dist/theme/pref.d.ts +39 -0
  256. package/dist/theme/pref.d.ts.map +1 -0
  257. package/dist/theme/pref.js +129 -0
  258. package/dist/theme/pref.js.map +1 -0
  259. package/dist/theme/provider.d.ts +41 -0
  260. package/dist/theme/provider.d.ts.map +1 -0
  261. package/dist/theme/provider.js +103 -0
  262. package/dist/theme/provider.js.map +1 -0
  263. package/dist/theme/store.d.ts +104 -0
  264. package/dist/theme/store.d.ts.map +1 -0
  265. package/dist/theme/store.js +509 -0
  266. package/dist/theme/store.js.map +1 -0
  267. package/package.json +94 -3
  268. package/styles.css +79 -0
  269. package/theme.css +186 -0
  270. package/vendor/shadcn-tailwind.css +664 -0
@@ -0,0 +1,950 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { converter } from "culori/fn";
3
+ import { LockIcon, LockOpenIcon, PipetteIcon, RotateCcwIcon, } from "lucide-react";
4
+ import { Slider as SliderPrimitive } from "radix-ui";
5
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
6
+ import { contrastRatio, formatColor, gamutOf, mapToSrgb, maxChroma, parseColor, } from "../lib/color.js";
7
+ import { cn } from "../lib/utils.js";
8
+ import { Button } from "./button.js";
9
+ import { Input } from "./input.js";
10
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
11
+ // The modes these converters need are registered by ../lib/color.ts, imported above.
12
+ const toRgb = converter("rgb");
13
+ const toP3 = converter("p3");
14
+ const toHsv = converter("hsv");
15
+ const toHsl = converter("hsl");
16
+ const toOklch = converter("oklch");
17
+ const ALL_SURFACES = ["oklch", "absolute", "hsv"];
18
+ const SURFACE_LABELS = {
19
+ oklch: "OKLCH",
20
+ absolute: "Absolute",
21
+ hsv: "HSV",
22
+ };
23
+ // A token written to three decimals on its gamut's edge can read a hair past it; that is not
24
+ // beyond the edge.
25
+ const PIN_SLACK = 1e-3;
26
+ // The absolute surface past P3: the row's edge colour with its chroma cut and its lightness pulled
27
+ // toward the middle, so the region reads as "not here" rather than as more colours.
28
+ const FADE = 0.38;
29
+ const FADE_L0 = 0.2;
30
+ const FADE_L1 = 0.72;
31
+ // Converting through sRGB lands a grey a hair off the axis; below this it has no hue to trust.
32
+ const ACHROMATIC = 1e-4;
33
+ const EDGE = 1e-6;
34
+ const BLACK = { l: 0, c: 0, h: 0, alpha: 1 };
35
+ const CHECKERBOARD = {
36
+ backgroundImage: "conic-gradient(#ccc 25%, #fff 0 50%, #ccc 0 75%, #fff 0)",
37
+ backgroundSize: "8px 8px",
38
+ };
39
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
40
+ const wrapHue = (h) => ((h % 360) + 360) % 360;
41
+ const round = (v, digits) => String(Number(v.toFixed(digits)));
42
+ function css(color, alpha = color.alpha) {
43
+ return `oklch(${color.l} ${color.c} ${color.h} / ${alpha})`;
44
+ }
45
+ function culoriOf(color) {
46
+ return { mode: "oklch", ...color };
47
+ }
48
+ /** An sRGB colour as oklch, keeping `hue` when it comes out achromatic. */
49
+ function oklchOfRgb(rgb, hue, alpha) {
50
+ const o = toOklch({ mode: "rgb", ...rgb });
51
+ const c = o.c ?? 0;
52
+ return {
53
+ l: clamp(o.l ?? 0, 0, 1),
54
+ c,
55
+ h: c < ACHROMATIC || o.h === undefined ? hue : wrapHue(o.h),
56
+ alpha,
57
+ };
58
+ }
59
+ function srgbOf(color) {
60
+ const rgb = toRgb(culoriOf(mapToSrgb(color)));
61
+ return {
62
+ r: clamp(rgb.r, 0, 1),
63
+ g: clamp(rgb.g, 0, 1),
64
+ b: clamp(rgb.b, 0, 1),
65
+ };
66
+ }
67
+ /**
68
+ * HSV of the colour. A grey has no HSV hue, and black no saturation either; `hold` carries what
69
+ * the HSV surface last set, so the thumb does not jump when the colour passes through them.
70
+ */
71
+ function hsvOf(color, hold) {
72
+ const raw = toHsv({ mode: "rgb", ...srgbOf(color) });
73
+ let h = hold?.h ?? raw.h;
74
+ if (h === undefined || Number.isNaN(h)) {
75
+ // No hold and no hue: borrow the oklch hue, so switching surfaces keeps the hue the user set.
76
+ h = toHsv(toRgb(culoriOf({ l: 0.7, c: 0.1, h: color.h, alpha: 1 }))).h ?? 0;
77
+ }
78
+ return { h: wrapHue(h), s: hold?.s ?? raw.s, v: raw.v };
79
+ }
80
+ function fromHsv(h, s, v, prev) {
81
+ const rgb = toRgb({ mode: "hsv", h, s, v });
82
+ return oklchOfRgb({ r: clamp(rgb.r, 0, 1), g: clamp(rgb.g, 0, 1), b: clamp(rgb.b, 0, 1) }, prev.h, prev.alpha);
83
+ }
84
+ function inUnit(c) {
85
+ return (c.r >= -EDGE &&
86
+ c.r <= 1 + EDGE &&
87
+ c.g >= -EDGE &&
88
+ c.g <= 1 + EDGE &&
89
+ c.b >= -EDGE &&
90
+ c.b <= 1 + EDGE);
91
+ }
92
+ const byte = (v) => Math.round(clamp(v, 0, 1) * 255);
93
+ function supportsP3() {
94
+ return (typeof window !== "undefined" &&
95
+ typeof window.matchMedia === "function" &&
96
+ window.matchMedia("(color-gamut: p3)").matches);
97
+ }
98
+ /** A 2D context in Display P3 when the display is P3 and the canvas accepts it. */
99
+ function context2d(canvas, p3) {
100
+ let ctx = null;
101
+ try {
102
+ ctx = canvas.getContext("2d", p3 ? { colorSpace: "display-p3" } : undefined);
103
+ }
104
+ catch {
105
+ // happy-dom and older engines: no canvas, or no colour space option.
106
+ }
107
+ if (ctx === null)
108
+ return null;
109
+ const space = p3 ? ctx.getContextAttributes?.().colorSpace : "srgb";
110
+ return { ctx, p3: space === "display-p3" };
111
+ }
112
+ const axisCache = new Map();
113
+ /**
114
+ * The absolute surface's chroma axis at a hue: a little past the largest P3 chroma the hue reaches
115
+ * at any lightness, so the hue's whole P3 wedge spans the width. Cached per 0.1° — at most 3600
116
+ * entries — because finding it takes milliseconds and a hue drag asks every frame.
117
+ */
118
+ function axisMax(hue) {
119
+ const key = Math.round(wrapHue(hue) * 10) % 3600;
120
+ const hit = axisCache.get(key);
121
+ if (hit !== undefined)
122
+ return hit;
123
+ const h = key / 10;
124
+ let bestL = 0.5;
125
+ let best = 0;
126
+ for (let l = 0.005; l < 1; l += 0.005) {
127
+ const c = maxChroma(l, h, "p3");
128
+ if (c > best) {
129
+ best = c;
130
+ bestL = l;
131
+ }
132
+ }
133
+ // Chroma rises to one cusp and falls, so a ternary search around the best sample finds its top.
134
+ let a = Math.max(0.001, bestL - 0.005);
135
+ let b = Math.min(0.999, bestL + 0.005);
136
+ for (let k = 0; k < 30; k++) {
137
+ const m1 = a + (b - a) / 3;
138
+ const m2 = b - (b - a) / 3;
139
+ if (maxChroma(m1, h, "p3") < maxChroma(m2, h, "p3"))
140
+ a = m1;
141
+ else
142
+ b = m2;
143
+ }
144
+ const peak = Math.max(best, maxChroma((a + b) / 2, h, "p3"));
145
+ const max = peak * 1.05;
146
+ axisCache.set(key, max);
147
+ return max;
148
+ }
149
+ /**
150
+ * Where a colour sits on the oklch surface: C as a share of the gamut's largest chroma at its
151
+ * lightness and hue. A grey at L = 0 or 1, where that largest chroma is 0, is 0; a colour with
152
+ * chroma there is infinitely far past the edge.
153
+ */
154
+ function relativeOf(color, gamut) {
155
+ const max = maxChroma(color.l, color.h, gamut);
156
+ if (max > 0)
157
+ return color.c / max;
158
+ return color.c < ACHROMATIC ? 0 : Number.POSITIVE_INFINITY;
159
+ }
160
+ /**
161
+ * The chroma lock: `next` with its C recomputed so that C / maxChroma stays what it was in `prev`,
162
+ * or what the oklch surface holds in `rel`. A colour beyond the gamut keeps its C, so the lock does
163
+ * not amplify the overshoot.
164
+ */
165
+ function lockChroma(prev, next, gamut, rel) {
166
+ const r = rel ?? relativeOf(prev, gamut);
167
+ if (r > 1 + PIN_SLACK)
168
+ return { color: next, rel: null };
169
+ const kept = Math.min(r, 1);
170
+ return {
171
+ color: { ...next, c: kept * maxChroma(next.l, next.h, gamut) },
172
+ rel: kept,
173
+ };
174
+ }
175
+ /** One RGBA image the size of the canvas, in the canvas's colour space. */
176
+ function imageFor(canvas, wantP3) {
177
+ const target = context2d(canvas, wantP3);
178
+ if (target === null)
179
+ return null;
180
+ const { ctx, p3 } = target;
181
+ const w = canvas.width;
182
+ const h = canvas.height;
183
+ if (w < 2 || h < 2)
184
+ return null;
185
+ const image = ctx.createImageData(w, h, {
186
+ colorSpace: p3 ? "display-p3" : "srgb",
187
+ });
188
+ const show = (pixel) => channels(p3 ? toP3(pixel) : toRgb(pixel));
189
+ const put = (i, out) => {
190
+ image.data[i] = byte(out[0]);
191
+ image.data[i + 1] = byte(out[1]);
192
+ image.data[i + 2] = byte(out[2]);
193
+ image.data[i + 3] = 255;
194
+ };
195
+ return { ctx, p3, w, h, image, show, put };
196
+ }
197
+ // Pixels are sampled at their centres, so the painted surface agrees with the thumb and the edge
198
+ // lines, which put L = 1 on the top border and C = 0 on the left one.
199
+ const rowLightness = (y, rows) => 1 - (y + 0.5) / rows;
200
+ const columnShare = (x, columns) => (x + 0.5) / columns;
201
+ /**
202
+ * Paints lightness × relative chroma at one hue: each row runs from grey to the gamut's edge at
203
+ * that lightness, so every pixel is a colour the gamut holds. With a P3 gamut, the band past sRGB
204
+ * is the real P3 colour on a P3 canvas, else the row's sRGB edge colour.
205
+ */
206
+ function paintRelative(canvas, hue, gamut, wantP3) {
207
+ const target = imageFor(canvas, wantP3);
208
+ if (target === null)
209
+ return;
210
+ const { ctx, p3, w, h, image, show, put } = target;
211
+ const pixel = { mode: "oklch", l: 0, c: 0, h: hue };
212
+ for (let y = 0; y < h; y++) {
213
+ pixel.l = rowLightness(y, h);
214
+ const span = maxChroma(pixel.l, hue, gamut);
215
+ const cs = gamut === "srgb" ? span : maxChroma(pixel.l, hue, "srgb");
216
+ pixel.c = cs;
217
+ const edge = channels(toRgb(pixel));
218
+ for (let x = 0; x < w; x++) {
219
+ pixel.c = columnShare(x, w) * span;
220
+ const i = (y * w + x) * 4;
221
+ put(i, pixel.c <= cs || p3 ? show(pixel) : edge);
222
+ }
223
+ }
224
+ ctx.putImageData(image, 0, 0);
225
+ }
226
+ /**
227
+ * Paints lightness × chroma at one hue, the chroma axis fitted to the hue's P3 wedge. Inside sRGB
228
+ * is the colour itself; between sRGB and P3 is the real P3 colour on a P3 canvas, else the row's
229
+ * sRGB edge colour, which is the same chroma reduction mapToSrgb performs; past P3 is a faded veil.
230
+ */
231
+ function paintAbsolute(canvas, hue, wantP3) {
232
+ const target = imageFor(canvas, wantP3);
233
+ if (target === null)
234
+ return;
235
+ const { ctx, p3, w, h, image, show, put } = target;
236
+ const span = axisMax(hue);
237
+ const pixel = { mode: "oklch", l: 0, c: 0, h: hue };
238
+ for (let y = 0; y < h; y++) {
239
+ const l = rowLightness(y, h);
240
+ const cs = maxChroma(l, hue, "srgb");
241
+ const cp = maxChroma(l, hue, "p3");
242
+ pixel.l = l;
243
+ pixel.c = cs;
244
+ const edge = channels(toRgb(pixel));
245
+ const veil = show({
246
+ mode: "oklch",
247
+ l: FADE_L0 + FADE_L1 * l,
248
+ c: (p3 ? cp : cs) * FADE,
249
+ h: hue,
250
+ });
251
+ for (let x = 0; x < w; x++) {
252
+ pixel.c = columnShare(x, w) * span;
253
+ const i = (y * w + x) * 4;
254
+ if (pixel.c <= cs)
255
+ put(i, show(pixel));
256
+ else if (pixel.c <= cp)
257
+ put(i, p3 ? show(pixel) : edge);
258
+ else
259
+ put(i, veil);
260
+ }
261
+ }
262
+ ctx.putImageData(image, 0, 0);
263
+ }
264
+ /**
265
+ * The gamut edges a surface marks: sRGB's on a P3 oklch surface, sRGB's and P3's on the absolute
266
+ * one. Sampled at the canvas's row centres so a line follows the painted boundary, and drawn over
267
+ * the canvas rather than into it: the canvas is painted at half size and scaled up, and a line in
268
+ * it is scaled up into a blur.
269
+ */
270
+ function edgeLines(surface, hue, gamut, rows) {
271
+ if (surface === "hsv" || rows < 2)
272
+ return [];
273
+ const ys = [
274
+ 0,
275
+ ...Array.from({ length: rows }, (_, y) => (y + 0.5) / rows),
276
+ 1,
277
+ ];
278
+ if (surface === "oklch") {
279
+ if (gamut === "srgb")
280
+ return [];
281
+ const xs = ys.map((y) => {
282
+ const span = maxChroma(1 - y, hue, "p3");
283
+ return span > 0 ? maxChroma(1 - y, hue, "srgb") / span : 0;
284
+ });
285
+ // At L = 0 and 1 both edges are 0 and the share has no value; the ends take their neighbour's.
286
+ xs[0] = xs[1];
287
+ xs[xs.length - 1] = xs[xs.length - 2];
288
+ return [
289
+ {
290
+ gamut: "srgb",
291
+ dashed: false,
292
+ points: ys.map((y, i) => [xs[i], y]),
293
+ },
294
+ ];
295
+ }
296
+ const span = axisMax(hue);
297
+ const line = (edge, dashed) => ({
298
+ gamut: edge,
299
+ dashed,
300
+ points: ys.map((y) => [maxChroma(1 - y, hue, edge) / span, y]),
301
+ });
302
+ return [line("p3", true), line("srgb", false)];
303
+ }
304
+ function channels(c) {
305
+ return [c.r, c.g, c.b];
306
+ }
307
+ /** The oklch hue strip at the current lightness and chroma, mapped into sRGB per hue. */
308
+ function paintHue(canvas, l, c) {
309
+ const target = context2d(canvas, false);
310
+ if (target === null)
311
+ return;
312
+ const { ctx } = target;
313
+ const w = canvas.width;
314
+ const image = ctx.createImageData(w, 1);
315
+ // A grey's strip would be one flat grey; a floor on chroma keeps the hues tellable apart.
316
+ const want = Math.max(c, 0.1);
317
+ for (let x = 0; x < w; x++) {
318
+ const hue = (x / (w - 1)) * 360;
319
+ let lo = 0;
320
+ let hi = want;
321
+ if (!inUnit(toRgb({ mode: "oklch", l, c: hi, h: hue }))) {
322
+ for (let k = 0; k < 12; k++) {
323
+ const mid = (lo + hi) / 2;
324
+ if (inUnit(toRgb({ mode: "oklch", l, c: mid, h: hue })))
325
+ lo = mid;
326
+ else
327
+ hi = mid;
328
+ }
329
+ hi = lo;
330
+ }
331
+ const rgb = toRgb({ mode: "oklch", l, c: hi, h: hue });
332
+ image.data[x * 4] = byte(rgb.r);
333
+ image.data[x * 4 + 1] = byte(rgb.g);
334
+ image.data[x * 4 + 2] = byte(rgb.b);
335
+ image.data[x * 4 + 3] = 255;
336
+ }
337
+ ctx.putImageData(image, 0, 0);
338
+ }
339
+ function stateFor(value, prev, alpha) {
340
+ const parsed = parseColor(value);
341
+ if (parsed === null) {
342
+ return {
343
+ color: BLACK,
344
+ hold: null,
345
+ rel: null,
346
+ relGamut: null,
347
+ format: prev?.format ?? "oklch",
348
+ draft: value,
349
+ seen: value,
350
+ emitted: null,
351
+ };
352
+ }
353
+ const { color, format } = parsed;
354
+ return {
355
+ color: {
356
+ ...color,
357
+ h: color.c < ACHROMATIC && prev !== null ? prev.color.h : color.h,
358
+ alpha: alpha ? color.alpha : 1,
359
+ },
360
+ hold: null,
361
+ rel: null,
362
+ relGamut: null,
363
+ format: format ?? "oklch",
364
+ draft: null,
365
+ seen: value,
366
+ emitted: null,
367
+ };
368
+ }
369
+ function gradeOf(ratio) {
370
+ if (ratio >= 7)
371
+ return "AAA";
372
+ if (ratio >= 4.5)
373
+ return "AA";
374
+ if (ratio >= 3)
375
+ return "AA large";
376
+ return "fails";
377
+ }
378
+ function isGrouped(swatches) {
379
+ return swatches.length > 0 && typeof swatches[0] !== "string";
380
+ }
381
+ function eyeDropperClass() {
382
+ if (typeof window === "undefined")
383
+ return null;
384
+ const ctor = window.EyeDropper;
385
+ return ctor ?? null;
386
+ }
387
+ /** A controlled colour input: area, hue and alpha sliders, text and channel fields, swatches. */
388
+ export function ColorPicker({ value, onValueChange, onValueCommit, format, alpha = true, surfaces = ALL_SURFACES, surface, defaultSurface, onSurfaceChange, gamut = "srgb", resetValue, chromaLock, defaultChromaLock = false, onChromaLockChange, swatches, contrastWith, contrastLabel = "background", eyeDropper = true, disabled = false, className, extra, }) {
389
+ const [st, setSt] = useState(() => stateFor(value, null, alpha));
390
+ const [initial] = useState(() => parseColor(value)?.color ?? null);
391
+ const [picked, setPicked] = useState(defaultSurface);
392
+ const [lockPicked, setLockPicked] = useState(defaultChromaLock);
393
+ const latest = useRef(st);
394
+ latest.current = st;
395
+ const uncommitted = useRef(false);
396
+ const id = useId();
397
+ // A value that is not the panel's own last output replaces what it shows; an echo changes
398
+ // nothing, so the full-precision state survives the round trip through a string.
399
+ let state = st;
400
+ if (value !== st.seen) {
401
+ state =
402
+ value === st.emitted
403
+ ? { ...st, seen: value }
404
+ : stateFor(value, st, alpha);
405
+ latest.current = state;
406
+ setSt(state);
407
+ }
408
+ const outFormat = format ?? state.format;
409
+ const color = alpha ? state.color : { ...state.color, alpha: 1 };
410
+ const formatted = formatColor(color, outFormat, { alpha });
411
+ const offered = surfaces.length > 0 ? surfaces : ALL_SURFACES;
412
+ const wanted = surface ?? picked ?? (outFormat === "oklch" ? "oklch" : "hsv");
413
+ const shown = offered.includes(wanted) ? wanted : offered[0];
414
+ const locked = chromaLock ?? lockPicked;
415
+ const update = (next, commit) => {
416
+ const out = formatColor(next.color, format ?? next.format, { alpha });
417
+ const changed = out !== latest.current.emitted;
418
+ next = { ...next, emitted: out };
419
+ latest.current = next;
420
+ setSt(next);
421
+ if (changed || commit)
422
+ onValueChange?.(out);
423
+ if (commit) {
424
+ uncommitted.current = false;
425
+ onValueCommit?.(out);
426
+ }
427
+ else if (changed) {
428
+ uncommitted.current = true;
429
+ }
430
+ };
431
+ const setColor = (next, commit, extra) => {
432
+ update({
433
+ ...latest.current,
434
+ hold: null,
435
+ rel: null,
436
+ draft: null,
437
+ ...extra,
438
+ relGamut: gamut,
439
+ color: alpha ? next : { ...next, alpha: 1 },
440
+ }, commit);
441
+ };
442
+ const commitPending = () => {
443
+ if (uncommitted.current)
444
+ update(latest.current, true);
445
+ };
446
+ const heldRel = (s) => (s.relGamut === gamut ? s.rel : null);
447
+ const pickSurface = (next) => {
448
+ setPicked(next);
449
+ onSurfaceChange?.(next);
450
+ };
451
+ const toggleLock = () => {
452
+ setLockPicked(!locked);
453
+ onChromaLockChange?.(!locked);
454
+ };
455
+ // --- area ---
456
+ const hsv = hsvOf(color, state.hold);
457
+ const rel = shown === "oklch" ? (heldRel(state) ?? relativeOf(color, gamut)) : 0;
458
+ const areaRaw = shown === "oklch"
459
+ ? rel
460
+ : shown === "absolute"
461
+ ? color.c / axisMax(color.h)
462
+ : clamp(hsv.s, 0, 1);
463
+ const pinned = areaRaw > 1 + (shown === "oklch" ? PIN_SLACK : 0);
464
+ const areaX = Math.min(1, areaRaw);
465
+ const areaY = shown === "hsv" ? 1 - hsv.v : 1 - color.l;
466
+ const gamutName = gamut === "p3" ? "P3" : "sRGB";
467
+ const setFromArea = (x, y, commit) => {
468
+ const cur = latest.current;
469
+ const l = clamp(1 - y, 0, 1);
470
+ if (shown === "oklch") {
471
+ const r = clamp(x, 0, 1);
472
+ setColor({ ...cur.color, l, c: r * maxChroma(l, cur.color.h, gamut) }, commit, { rel: r });
473
+ }
474
+ else if (shown === "absolute") {
475
+ setColor({ ...cur.color, l, c: clamp(x, 0, 1) * axisMax(cur.color.h) }, commit);
476
+ }
477
+ else {
478
+ const h = hsvOf(cur.color, cur.hold).h;
479
+ const s = clamp(x, 0, 1);
480
+ setColor(fromHsv(h, s, l, cur.color), commit, {
481
+ hold: { h, s },
482
+ });
483
+ }
484
+ };
485
+ const dragging = useRef(false);
486
+ const pointAt = (event) => {
487
+ const box = event.currentTarget.getBoundingClientRect();
488
+ return [
489
+ box.width > 0 ? (event.clientX - box.left) / box.width : 0,
490
+ box.height > 0 ? (event.clientY - box.top) / box.height : 0,
491
+ ];
492
+ };
493
+ const onAreaKey = (event) => {
494
+ const step = event.shiftKey ? 10 : 1;
495
+ const delta = {
496
+ ArrowLeft: [-step, 0],
497
+ ArrowRight: [step, 0],
498
+ ArrowUp: [0, step],
499
+ ArrowDown: [0, -step],
500
+ }[event.key];
501
+ if (delta === undefined)
502
+ return;
503
+ event.preventDefault();
504
+ const cur = latest.current;
505
+ if (shown === "oklch") {
506
+ const r = heldRel(cur) ?? relativeOf(cur.color, gamut);
507
+ if (delta[0] !== 0) {
508
+ // From beyond the edge, the first horizontal step starts at the edge.
509
+ const x = clamp(Math.min(r, 1) + delta[0] * 0.01, 0, 1);
510
+ const c = x * maxChroma(cur.color.l, cur.color.h, gamut);
511
+ setColor({ ...cur.color, c }, true, { rel: x });
512
+ }
513
+ else {
514
+ const l = clamp(cur.color.l + delta[1] * 0.01, 0, 1);
515
+ if (r > 1 + PIN_SLACK) {
516
+ // Beyond the edge, a vertical step keeps C rather than clipping it to the edge.
517
+ setColor({ ...cur.color, l }, true);
518
+ }
519
+ else {
520
+ const x = Math.min(r, 1);
521
+ const c = x * maxChroma(l, cur.color.h, gamut);
522
+ setColor({ ...cur.color, l, c }, true, { rel: x });
523
+ }
524
+ }
525
+ }
526
+ else if (shown === "absolute") {
527
+ setColor({
528
+ ...cur.color,
529
+ l: clamp(cur.color.l + delta[1] * 0.01, 0, 1),
530
+ // A vertical step leaves chroma alone, even one typed past the area's axis.
531
+ c: delta[0] === 0
532
+ ? cur.color.c
533
+ : clamp(cur.color.c + delta[0] * 0.004, 0, Math.max(axisMax(cur.color.h), cur.color.c)),
534
+ }, true);
535
+ }
536
+ else {
537
+ const now = hsvOf(cur.color, cur.hold);
538
+ const s = clamp(now.s + delta[0] / 100, 0, 1);
539
+ const v = clamp(now.v + delta[1] / 100, 0, 1);
540
+ setColor(fromHsv(now.h, s, v, cur.color), true, {
541
+ hold: { h: now.h, s },
542
+ });
543
+ }
544
+ };
545
+ // --- canvases ---
546
+ const areaRef = useRef(null);
547
+ const areaCanvas = useRef(null);
548
+ const hueCanvas = useRef(null);
549
+ const [areaSize, setAreaSize] = useState({ w: 0, h: 0 });
550
+ const [p3] = useState(supportsP3);
551
+ useEffect(() => {
552
+ const el = areaRef.current;
553
+ if (el === null || typeof ResizeObserver === "undefined")
554
+ return;
555
+ const observer = new ResizeObserver(([entry]) => {
556
+ const { width, height } = entry.contentRect;
557
+ setAreaSize({ w: width, h: height });
558
+ });
559
+ observer.observe(el);
560
+ return () => observer.disconnect();
561
+ }, []);
562
+ // The colour field is painted at half size: a smooth gradient survives the upscale, and the
563
+ // per-pixel paint costs a quarter.
564
+ const raster = {
565
+ w: Math.round(areaSize.w / 2),
566
+ h: Math.round(areaSize.h / 2),
567
+ };
568
+ const areaHue = color.h;
569
+ useEffect(() => {
570
+ if (shown === "hsv")
571
+ return;
572
+ const canvas = areaCanvas.current;
573
+ if (canvas === null || raster.w < 2 || raster.h < 2)
574
+ return;
575
+ // Hue drags arrive faster than a raster can paint; one draw per frame is enough.
576
+ const frame = requestAnimationFrame(() => {
577
+ canvas.width = raster.w;
578
+ canvas.height = raster.h;
579
+ if (shown === "oklch")
580
+ paintRelative(canvas, areaHue, gamut, p3);
581
+ else
582
+ paintAbsolute(canvas, areaHue, p3);
583
+ });
584
+ return () => cancelAnimationFrame(frame);
585
+ }, [shown, areaHue, raster.w, raster.h, gamut, p3]);
586
+ const edges = useMemo(() => edgeLines(shown, areaHue, gamut, raster.h), [shown, areaHue, gamut, raster.h]);
587
+ const { l: lightness, c: chroma } = color;
588
+ useEffect(() => {
589
+ if (shown === "hsv")
590
+ return;
591
+ const canvas = hueCanvas.current;
592
+ if (canvas === null)
593
+ return;
594
+ const frame = requestAnimationFrame(() => {
595
+ canvas.width = 180;
596
+ canvas.height = 1;
597
+ paintHue(canvas, lightness, chroma);
598
+ });
599
+ return () => cancelAnimationFrame(frame);
600
+ }, [shown, lightness, chroma]);
601
+ // --- hue and alpha ---
602
+ const hueValue = shown === "hsv" ? hsv.h : color.h;
603
+ const setHue = (hue, commit) => {
604
+ const cur = latest.current;
605
+ if (shown !== "hsv") {
606
+ // Not wrapped: 360 is the slider's right end, and wrapping it to 0 would throw the thumb
607
+ // to the left end mid-drag. formatColor writes it as 0.
608
+ const next = { ...cur.color, h: clamp(hue, 0, 360) };
609
+ if (locked) {
610
+ const kept = lockChroma(cur.color, next, gamut, heldRel(cur));
611
+ setColor(kept.color, commit, { rel: kept.rel });
612
+ }
613
+ else {
614
+ setColor(next, commit);
615
+ }
616
+ }
617
+ else {
618
+ const now = hsvOf(cur.color, cur.hold);
619
+ const h = clamp(hue, 0, 360);
620
+ setColor(fromHsv(h, now.s, now.v, cur.color), commit, {
621
+ hold: { h, s: now.s },
622
+ });
623
+ }
624
+ };
625
+ const setChannel = (next, key) => {
626
+ const cur = latest.current;
627
+ if (locked &&
628
+ shown !== "hsv" &&
629
+ outFormat === "oklch" &&
630
+ (key === "l" || key === "h")) {
631
+ const kept = lockChroma(cur.color, next, gamut, heldRel(cur));
632
+ setColor(kept.color, true, { draft: null, rel: kept.rel });
633
+ }
634
+ else {
635
+ setColor(next, true, { draft: null });
636
+ }
637
+ };
638
+ const setAlpha = (percent, commit) => {
639
+ const cur = latest.current;
640
+ update({ ...cur, color: { ...cur.color, alpha: clamp(percent / 100, 0, 1) } }, commit);
641
+ };
642
+ // --- text field ---
643
+ const textInvalid = state.draft !== null && parseColor(state.draft) === null;
644
+ const onText = (text) => {
645
+ const parsed = parseColor(text);
646
+ const cur = latest.current;
647
+ if (parsed === null) {
648
+ setSt({ ...cur, draft: text });
649
+ latest.current = { ...cur, draft: text };
650
+ return;
651
+ }
652
+ setColor({
653
+ ...parsed.color,
654
+ h: parsed.color.c < ACHROMATIC ? cur.color.h : parsed.color.h,
655
+ }, false, { draft: text, format: parsed.format ?? cur.format });
656
+ };
657
+ const endText = () => {
658
+ const cur = latest.current;
659
+ if (cur.draft !== null) {
660
+ latest.current = { ...cur, draft: null };
661
+ setSt(latest.current);
662
+ }
663
+ };
664
+ // --- eyedropper ---
665
+ const EyeDropper = eyeDropperClass();
666
+ const showEyeDropper = eyeDropper && !disabled && EyeDropper !== null;
667
+ const pickFromScreen = async () => {
668
+ if (EyeDropper === null)
669
+ return;
670
+ let hex;
671
+ try {
672
+ hex = (await new EyeDropper().open()).sRGBHex;
673
+ }
674
+ catch {
675
+ return; // cancelled
676
+ }
677
+ const parsed = parseColor(hex);
678
+ if (parsed === null)
679
+ return;
680
+ const cur = latest.current;
681
+ setColor({
682
+ ...parsed.color,
683
+ h: parsed.color.c < ACHROMATIC ? cur.color.h : parsed.color.h,
684
+ alpha: cur.color.alpha,
685
+ }, true, { draft: null });
686
+ };
687
+ // --- chips ---
688
+ const colorGamut = gamutOf(color);
689
+ const contrastTarget = contrastWith === undefined ? null : parseColor(contrastWith);
690
+ const ratio = contrastTarget === null ? null : contrastRatio(color, contrastTarget.color);
691
+ // --- swatches ---
692
+ const groups = swatches === undefined || swatches.length === 0
693
+ ? []
694
+ : isGrouped(swatches)
695
+ ? [...swatches]
696
+ : [{ label: "Swatches", colors: swatches }];
697
+ // A grey takes the panel's hue when picked, so it is compared with it too.
698
+ const isCurrent = (parsed) => formatColor({
699
+ ...parsed,
700
+ h: parsed.c < ACHROMATIC ? color.h : parsed.h,
701
+ alpha: alpha ? parsed.alpha : 1,
702
+ }, outFormat, { alpha }) === formatted;
703
+ const resetParsed = resetValue === undefined ? null : parseColor(resetValue);
704
+ const reset = resetValue === undefined || resetParsed === null
705
+ ? null
706
+ : { color: resetParsed.color, format: resetParsed.format };
707
+ // Reset adopts the reset value's format unless `format` pins one, so it is only done once the
708
+ // output is in that format too.
709
+ const atReset = reset !== null &&
710
+ (format !== undefined ||
711
+ reset.format === null ||
712
+ reset.format === state.format) &&
713
+ isCurrent(reset.color);
714
+ const resetTo = () => {
715
+ if (reset === null)
716
+ return;
717
+ const cur = latest.current;
718
+ setColor({
719
+ ...reset.color,
720
+ h: reset.color.c < ACHROMATIC ? cur.color.h : reset.color.h,
721
+ }, true, { draft: null, format: reset.format ?? cur.format });
722
+ };
723
+ const pickSwatch = (text) => {
724
+ const parsed = parseColor(text);
725
+ if (parsed === null)
726
+ return;
727
+ const cur = latest.current;
728
+ setColor({
729
+ ...parsed.color,
730
+ h: parsed.color.c < ACHROMATIC ? cur.color.h : parsed.color.h,
731
+ }, true, { draft: null });
732
+ };
733
+ return (_jsxs("div", { "data-slot": "color-picker", className: cn("grid w-64 gap-2 text-xs", className), children: [offered.length > 1 && (_jsx("fieldset", { "aria-label": "Picking surface", className: "grid gap-0.5 rounded-md bg-muted p-0.5", style: {
734
+ gridTemplateColumns: `repeat(${offered.length}, minmax(0, 1fr))`,
735
+ }, children: offered.map((s) => (_jsx("button", { type: "button", "aria-pressed": shown === s, disabled: disabled, onClick: () => pickSurface(s), className: "h-6 rounded-[5px] text-[0.7rem] font-medium text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-50 aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-xs", children: SURFACE_LABELS[s] }, s))) })), _jsxs("div", { ref: areaRef, role: "slider", tabIndex: disabled ? -1 : 0, "aria-label": "Colour", "aria-roledescription": "2D slider", "aria-disabled": disabled || undefined, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round((1 - areaY) * 100), "aria-valuetext": shown === "hsv"
736
+ ? `saturation ${round(hsv.s * 100, 1)}%, value ${round(hsv.v * 100, 1)}%`
737
+ : shown === "absolute"
738
+ ? `lightness ${round(color.l * 100, 1)}%, chroma ${round(color.c, 3)}`
739
+ : pinned
740
+ ? `lightness ${round(color.l * 100, 1)}%, chroma ${round(color.c, 3)}, beyond the ${gamutName} edge`
741
+ : `lightness ${round(color.l * 100, 1)}%, chroma ${round(color.c, 3)} (${Math.round(rel * 100)}% of ${gamutName} max)`, className: cn("relative h-42 cursor-crosshair touch-none overflow-hidden rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50", disabled && "pointer-events-none opacity-50"), style: shown === "hsv"
742
+ ? {
743
+ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${hsv.h} 100% 50%))`,
744
+ }
745
+ : undefined, onPointerDown: (event) => {
746
+ if (disabled || event.button !== 0)
747
+ return;
748
+ event.currentTarget.setPointerCapture?.(event.pointerId);
749
+ event.currentTarget.focus();
750
+ dragging.current = true;
751
+ const [x, y] = pointAt(event);
752
+ setFromArea(x, y, false);
753
+ }, onPointerMove: (event) => {
754
+ if (!dragging.current)
755
+ return;
756
+ const [x, y] = pointAt(event);
757
+ setFromArea(x, y, false);
758
+ }, onPointerUp: () => {
759
+ if (!dragging.current)
760
+ return;
761
+ dragging.current = false;
762
+ update(latest.current, true);
763
+ }, onPointerCancel: () => {
764
+ if (!dragging.current)
765
+ return;
766
+ dragging.current = false;
767
+ update(latest.current, true);
768
+ }, onKeyDown: disabled ? undefined : onAreaKey, children: [shown !== "hsv" && (_jsx("canvas", { ref: areaCanvas, className: "block size-full" })), edges.length > 0 && (_jsx("svg", { "aria-hidden": true, "data-gamut-edges": true, width: areaSize.w, height: areaSize.h, className: "pointer-events-none absolute inset-0", children: edges.map((edge) => (_jsx("polyline", { "data-edge": edge.gamut, points: edge.points
769
+ .map(([x, y]) => `${round(x * areaSize.w, 2)},${round(y * areaSize.h, 2)}`)
770
+ .join(" "), fill: "none", stroke: "rgb(255 255 255 / 0.9)", strokeWidth: 1, strokeLinejoin: "round", strokeDasharray: edge.dashed ? "6 4" : undefined }, edge.gamut))) })), _jsx("span", { "aria-hidden": true, "data-thumb": true, "data-pinned": pinned || undefined, className: "pointer-events-none absolute -mt-[7px] -ml-[7px] size-3.5 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.5),inset_0_0_0_1px_rgb(0_0_0/0.3)] data-pinned:outline-[1.5px] data-pinned:outline-offset-2 data-pinned:outline-destructive/70 data-pinned:outline-dashed", style: { left: `${areaX * 100}%`, top: `${areaY * 100}%` } }), _jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute top-1 left-1 rounded-sm bg-black/45 px-1 text-[10px] text-white", children: shown === "hsv" ? "V ↑" : "L ↑" }), _jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute right-1 bottom-1 rounded-sm bg-black/45 px-1 text-[10px] text-white", children: shown === "oklch" ? "C/max →" : shown === "absolute" ? "C →" : "S →" })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(TrackSlider, { label: "Hue", value: hueValue, max: 360, disabled: disabled, onChange: (v) => setHue(v, false), onCommit: (v) => setHue(v, true), trackStyle: shown === "hsv"
771
+ ? {
772
+ background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",
773
+ }
774
+ : undefined, children: shown !== "hsv" && (_jsx("canvas", { ref: hueCanvas, className: "absolute inset-0 block size-full" })) }), shown !== "hsv" && (_jsx(Button, { type: "button", variant: "ghost", size: "icon-xs", "aria-label": "Keep relative chroma", "aria-pressed": locked, title: locked
775
+ ? `Hue and lightness keep the share of the ${gamutName} maximum chroma`
776
+ : `Keep the share of the ${gamutName} maximum chroma when hue or lightness changes`, disabled: disabled, onClick: toggleLock, children: locked ? _jsx(LockIcon, {}) : _jsx(LockOpenIcon, {}) }))] }), alpha && (_jsx(TrackSlider, { label: "Alpha", value: color.alpha * 100, max: 100, disabled: disabled, onChange: (v) => setAlpha(v, false), onCommit: (v) => setAlpha(v, true), trackStyle: CHECKERBOARD, children: _jsx("span", { className: "absolute inset-0", style: {
777
+ background: `linear-gradient(to right, ${css(color, 0)}, ${css(color, 1)})`,
778
+ } }) })), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("div", { role: "img", "aria-label": "Before and after", title: "Before / after", className: "grid h-7 w-9 shrink-0 grid-cols-2 overflow-hidden rounded-md border", style: CHECKERBOARD, children: [_jsx("span", { style: {
779
+ background: initial === null
780
+ ? "transparent"
781
+ : css(initial, alpha ? initial.alpha : 1),
782
+ } }), _jsx("span", { style: { background: css(color) } })] }), _jsx(Input, { "aria-label": "Colour value", spellCheck: false, autoComplete: "off", disabled: disabled, "aria-invalid": textInvalid || undefined, value: state.draft ?? formatted, onChange: (event) => onText(event.target.value), onKeyDown: (event) => {
783
+ if (event.key === "Enter") {
784
+ if (parseColor(latest.current.draft ?? formatted) === null)
785
+ return;
786
+ update({ ...latest.current, draft: null }, true);
787
+ }
788
+ else if (event.key === "Escape") {
789
+ endText();
790
+ }
791
+ }, onBlur: () => {
792
+ endText();
793
+ commitPending();
794
+ }, className: "h-7 flex-1 px-1.5 font-mono text-base md:text-xs" }), showEyeDropper && (_jsx(Button, { type: "button", variant: "outline", size: "icon-sm", "aria-label": "Pick from screen", title: "Pick from screen", onClick: pickFromScreen, children: _jsx(PipetteIcon, {}) })), reset !== null && (_jsx(Button, { type: "button", variant: "outline", size: "icon-sm", "aria-label": "Reset", title: `Reset to ${resetValue}`, disabled: disabled || atReset, onClick: resetTo, children: _jsx(RotateCcwIcon, {}) }))] }), _jsx(ChannelFields, { color: color, format: outFormat, alpha: alpha, disabled: disabled, idPrefix: id, onSet: setChannel }), _jsxs("div", { className: "grid gap-1 text-[11px]", children: [_jsxs("div", { className: "flex h-5 items-center gap-1.5", children: [_jsx("span", { "data-gamut": colorGamut, title: colorGamut !== "srgb" && outFormat !== "oklch"
795
+ ? `Mapped to sRGB in ${outFormat}`
796
+ : undefined, className: cn("min-w-0 truncate rounded-full border px-1.5 py-px", colorGamut === "srgb"
797
+ ? "border-emerald-500/60 text-emerald-700 dark:text-emerald-300"
798
+ : colorGamut === "p3"
799
+ ? "border-amber-500/70 text-amber-700 dark:text-amber-300"
800
+ : "border-destructive/70 text-destructive"), children: colorGamut === "srgb"
801
+ ? "in sRGB"
802
+ : colorGamut === "p3"
803
+ ? "outside sRGB · in P3"
804
+ : "outside P3" }), _jsx("button", { type: "button", disabled: disabled, title: "Replace with the nearest colour inside sRGB", onClick: () => setColor(mapToSrgb(latest.current.color), true, {
805
+ draft: null,
806
+ }), className: "ml-auto shrink-0 underline underline-offset-2 disabled:opacity-50",
807
+ // visibility rather than unmounting, for the space; inline rather than a class, so
808
+ // jsdom sees it too and the button leaves the accessibility tree there as well.
809
+ style: colorGamut === "srgb" ? { visibility: "hidden" } : undefined, children: "map to sRGB" })] }), ratio !== null && (_jsx("div", { className: "flex h-5 items-center", children: _jsx("span", { "data-contrast": true, title: `WCAG 2 contrast against ${contrastWith}`, className: cn("min-w-0 truncate rounded-full border px-1.5 py-px", ratio >= 4.5
810
+ ? "border-emerald-500/60 text-emerald-700 dark:text-emerald-300"
811
+ : ratio >= 3
812
+ ? "border-amber-500/70 text-amber-700 dark:text-amber-300"
813
+ : "border-destructive/70 text-destructive"), children: `${(Math.floor(ratio * 100 + 1e-6) / 100).toFixed(2)}:1 vs ${contrastLabel} · ${gradeOf(ratio)}` }) }))] }), groups.map((group) => {
814
+ const usable = group.colors.flatMap((text) => {
815
+ const parsed = parseColor(text);
816
+ return parsed === null ? [] : [{ text, parsed }];
817
+ });
818
+ if (usable.length === 0)
819
+ return null;
820
+ return (_jsxs("div", { className: "grid gap-1", children: [_jsx("div", { className: "text-[10px] tracking-wide text-muted-foreground uppercase", children: group.label }), _jsx("div", { className: "grid grid-cols-10 gap-1", children: usable.map(({ text, parsed }) => {
821
+ const pressed = isCurrent(parsed.color);
822
+ return (_jsx("button", { type: "button", "aria-label": text, title: text, "aria-pressed": pressed, disabled: disabled, onClick: () => pickSwatch(text), className: "aspect-square rounded-full border border-black/15 outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50 aria-pressed:ring-2 aria-pressed:ring-foreground aria-pressed:ring-offset-2 aria-pressed:ring-offset-popover", style: {
823
+ ...CHECKERBOARD,
824
+ backgroundImage: `linear-gradient(${css(parsed.color)}, ${css(parsed.color)}), ${CHECKERBOARD.backgroundImage}`,
825
+ backgroundSize: `auto, ${CHECKERBOARD.backgroundSize}`,
826
+ } }, text));
827
+ }) })] }, group.label));
828
+ }), extra] }));
829
+ }
830
+ function TrackSlider({ label, value, max, disabled, onChange, onCommit, trackStyle, children, }) {
831
+ return (_jsxs(SliderPrimitive.Root, { value: [value], min: 0, max: max, step: 1, disabled: disabled, onValueChange: ([v]) => onChange(v), onValueCommit: ([v]) => onCommit(v), className: "relative flex h-3 w-full touch-none items-center select-none data-disabled:opacity-50", children: [_jsx(SliderPrimitive.Track, { className: "relative h-3 grow overflow-hidden rounded-full", style: trackStyle, children: children }), _jsx(SliderPrimitive.Thumb, { "aria-label": label, className: "block size-3.5 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.5),inset_0_0_0_1px_rgb(0_0_0/0.3)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50" })] }));
832
+ }
833
+ function channelSpecs(color, format, alpha) {
834
+ const specs = [];
835
+ if (format === "oklch") {
836
+ specs.push({
837
+ key: "l",
838
+ label: "L",
839
+ name: "Lightness",
840
+ text: round(color.l, 3),
841
+ apply: (n) => ({ ...color, l: clamp(n, 0, 1) }),
842
+ }, {
843
+ key: "c",
844
+ label: "C",
845
+ name: "Chroma",
846
+ text: round(color.c, 3),
847
+ apply: (n) => ({ ...color, c: Math.max(0, n) }),
848
+ }, {
849
+ key: "h",
850
+ label: "H",
851
+ name: "Hue",
852
+ text: round(color.h, 3),
853
+ apply: (n) => ({ ...color, h: wrapHue(n) }),
854
+ });
855
+ }
856
+ else if (format === "hsl") {
857
+ const srgb = srgbOf(color);
858
+ const hsl = toHsl({ mode: "rgb", ...srgb });
859
+ const grey = !(hsl.s > 1e-9) || !Number.isFinite(hsl.s);
860
+ const h = grey || hsl.h === undefined ? 0 : hsl.h;
861
+ const s = grey ? 0 : hsl.s;
862
+ const set = (next) => {
863
+ const rgb = toRgb({ mode: "hsl", ...next });
864
+ return oklchOfRgb({
865
+ r: clamp(rgb.r, 0, 1),
866
+ g: clamp(rgb.g, 0, 1),
867
+ b: clamp(rgb.b, 0, 1),
868
+ }, color.h, color.alpha);
869
+ };
870
+ specs.push({
871
+ key: "h",
872
+ label: "H",
873
+ name: "Hue",
874
+ text: round(h, 1),
875
+ apply: (n) => set({ h: wrapHue(n), s, l: hsl.l }),
876
+ }, {
877
+ key: "s",
878
+ label: "S%",
879
+ name: "Saturation",
880
+ text: round(s * 100, 1),
881
+ apply: (n) => set({ h, s: clamp(n, 0, 100) / 100, l: hsl.l }),
882
+ }, {
883
+ key: "l",
884
+ label: "L%",
885
+ name: "Lightness",
886
+ text: round(hsl.l * 100, 1),
887
+ apply: (n) => set({ h, s, l: clamp(n, 0, 100) / 100 }),
888
+ });
889
+ }
890
+ else {
891
+ const srgb = srgbOf(color);
892
+ const bytes = { r: byte(srgb.r), g: byte(srgb.g), b: byte(srgb.b) };
893
+ for (const [key, name] of [
894
+ ["r", "Red"],
895
+ ["g", "Green"],
896
+ ["b", "Blue"],
897
+ ]) {
898
+ specs.push({
899
+ key,
900
+ label: key.toUpperCase(),
901
+ name,
902
+ text: String(bytes[key]),
903
+ apply: (n) => {
904
+ const next = { ...bytes, [key]: Math.round(clamp(n, 0, 255)) };
905
+ return oklchOfRgb({ r: next.r / 255, g: next.g / 255, b: next.b / 255 }, color.h, color.alpha);
906
+ },
907
+ });
908
+ }
909
+ }
910
+ if (alpha) {
911
+ specs.push({
912
+ key: "a",
913
+ label: "A%",
914
+ name: "Alpha",
915
+ text: round(color.alpha * 100, 1),
916
+ apply: (n) => ({ ...color, alpha: clamp(n, 0, 100) / 100 }),
917
+ });
918
+ }
919
+ return specs;
920
+ }
921
+ function ChannelFields({ color, format, alpha, disabled, idPrefix, onSet, }) {
922
+ const specs = channelSpecs(color, format, alpha);
923
+ return (_jsx("div", { className: "grid gap-1", style: { gridTemplateColumns: `repeat(${specs.length}, minmax(0, 1fr))` }, children: specs.map((spec) => (_jsx(ChannelField, { id: `${idPrefix}-${spec.key}`, spec: spec, disabled: disabled, onSet: onSet }, `${format}-${spec.key}`))) }));
924
+ }
925
+ function ChannelField({ id, spec, disabled, onSet, }) {
926
+ const [draft, setDraft] = useState(null);
927
+ const settle = () => {
928
+ if (draft === null)
929
+ return;
930
+ setDraft(null);
931
+ const n = Number.parseFloat(draft);
932
+ if (!Number.isFinite(n) || draft.trim() === spec.text)
933
+ return;
934
+ onSet(spec.apply(n), spec.key);
935
+ };
936
+ return (_jsxs("div", { className: "grid gap-px text-center", children: [_jsx("label", { htmlFor: id, className: "text-[10px] text-muted-foreground", children: spec.label }), _jsx(Input, { id: id, "aria-label": spec.name, inputMode: "decimal", spellCheck: false, autoComplete: "off", disabled: disabled, value: draft ?? spec.text, onChange: (event) => setDraft(event.target.value), onKeyDown: (event) => {
937
+ if (event.key === "Enter")
938
+ settle();
939
+ else if (event.key === "Escape")
940
+ setDraft(null);
941
+ }, onBlur: settle, className: "h-6 px-1 text-center font-mono text-base md:text-[11px]" })] }));
942
+ }
943
+ /** The panel in a popover, behind a trigger showing the value. */
944
+ export function ColorPickerPopover({ open, defaultOpen, onOpenChange, align, side, children, "aria-label": ariaLabel = "Pick colour", ...picker }) {
945
+ const parsed = parseColor(picker.value);
946
+ return (_jsxs(Popover, { open: open, defaultOpen: defaultOpen, onOpenChange: onOpenChange, children: [_jsx(PopoverTrigger, { asChild: true, "aria-label": ariaLabel, disabled: picker.disabled, children: children ?? (_jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "pl-1 font-mono text-xs", children: [_jsx("span", { "aria-hidden": true, className: "size-5 shrink-0 overflow-hidden rounded-sm border border-black/15", style: CHECKERBOARD, children: _jsx("span", { className: "block size-full", style: {
947
+ background: parsed === null ? "transparent" : css(parsed.color),
948
+ } }) }), picker.value] })) }), _jsx(PopoverContent, { align: align, side: side, className: "w-auto p-2", children: _jsx(ColorPicker, { ...picker }) })] }));
949
+ }
950
+ //# sourceMappingURL=color-picker.js.map