authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-17 20:42:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 20:42:30-07:00
log994654198d5820b6fab9ee09063646a8b6fd8e22
tree8613e48f566451da34bcd07ecaf2ff288c1e47c4
parentcc39ec1008ce584e088e79210b69d3359357463b
signature Signed by PGP key B5690EEEBB952194

fix(web): share accessible color picker controls (#12355)


7 files changed, 616 insertions(+), 331 deletions(-)

apps/web/src/components/settings/AddProviderInstanceDialog.tsx+6-6
...@@ -21,6 +21,7 @@ import { Input } from "../ui/input";...@@ -21,6 +21,7 @@ import { Input } from "../ui/input";
21import { RadioGroup } from "../ui/radio-group";21import { RadioGroup } from "../ui/radio-group";
22import { toastManager } from "../ui/toast";22import { toastManager } from "../ui/toast";
23import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";23import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
24import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
24import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";25import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
25import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard";26import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard";
26import {27import {
...@@ -338,12 +339,11 @@ export function AddProviderInstanceDialog({...@@ -338,12 +339,11 @@ export function AddProviderInstanceDialog({
338 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>339 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
339 <span className="text-xs font-medium text-foreground">Accent color</span>340 <span className="text-xs font-medium text-foreground">Accent color</span>
340 <div className="flex min-w-0 flex-wrap items-center gap-2">341 <div className="flex min-w-0 flex-wrap items-center gap-2">
341 <input342 <ProviderAccentColorPicker
342 type="color"343 displayName={label || driverOption.label}
343 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}344 value={accentColor || undefined}
344 onChange={(event) => setAccentColor(event.target.value)}345 onCommit={setAccentColor}
345 aria-label="Provider instance accent color"346 layout="inline"
346 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
347 />347 />
348 <div className="flex flex-wrap gap-1.5">348 <div className="flex flex-wrap gap-1.5">
349 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {349 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
apps/web/src/components/settings/ProviderAccentColorPicker.tsx+21-127
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1"use client";1"use client";
22
3import { PipetteIcon, XIcon } from "lucide-react";3import { PipetteIcon, XIcon } from "lucide-react";
4import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";4import { useCallback, useEffect, useRef, useState } from "react";
55
6import { hexToHsv, hsvToHex, type HsvColor } from "../../lib/color";
7import { ColorHueSlider, ColorSaturationValuePlane } from "../ui/color-picker";
8import { Input } from "../ui/input";
6import { Button } from "../ui/button";9import { Button } from "../ui/button";
7import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";10import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
8import { normalizeProviderAccentColor } from "../../providerInstances";11import { normalizeProviderAccentColor } from "../../providerInstances";
...@@ -10,149 +13,40 @@ import { cn } from "../../lib/utils";...@@ -10,149 +13,40 @@ import { cn } from "../../lib/utils";
1013
11const FALLBACK_ACCENT_COLOR = "#2563eb";14const FALLBACK_ACCENT_COLOR = "#2563eb";
1215
13function clamp(value: number, min = 0, max = 1) {
14 return Math.min(max, Math.max(min, value));
15}
16
17function hexToHsv(hex: string) {
18 const normalized = normalizeProviderAccentColor(hex) ?? FALLBACK_ACCENT_COLOR;
19 const numeric = Number.parseInt(normalized.slice(1), 16);
20 const red = ((numeric >> 16) & 255) / 255;
21 const green = ((numeric >> 8) & 255) / 255;
22 const blue = (numeric & 255) / 255;
23 const max = Math.max(red, green, blue);
24 const min = Math.min(red, green, blue);
25 const delta = max - min;
26
27 let hue = 0;
28 if (delta !== 0) {
29 if (max === red) {
30 hue = ((green - blue) / delta) % 6;
31 } else if (max === green) {
32 hue = (blue - red) / delta + 2;
33 } else {
34 hue = (red - green) / delta + 4;
35 }
36 hue *= 60;
37 if (hue < 0) hue += 360;
38 }
39
40 return {
41 h: hue,
42 s: max === 0 ? 0 : delta / max,
43 v: max,
44 };
45}
46
47function hsvToHex(hue: number, saturation: number, value: number) {
48 const chroma = value * saturation;
49 const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
50 const match = value - chroma;
51 const [red, green, blue] =
52 hue < 60
53 ? [chroma, x, 0]
54 : hue < 120
55 ? [x, chroma, 0]
56 : hue < 180
57 ? [0, chroma, x]
58 : hue < 240
59 ? [0, x, chroma]
60 : hue < 300
61 ? [x, 0, chroma]
62 : [chroma, 0, x];
63
64 return `#${[red, green, blue]
65 .map((channel) =>
66 Math.round((channel + match) * 255)
67 .toString(16)
68 .padStart(2, "0"),
69 )
70 .join("")}`;
71}
72
73function ProviderCustomColorPanel(props: {16function ProviderCustomColorPanel(props: {
74 readonly value: string;17 readonly value: string;
75 readonly onCommit: (value: string) => void;18 readonly onCommit: (value: string) => void;
76}) {19}) {
77 const { onCommit } = props;20 const { onCommit } = props;
78 const initialHsv = useMemo(() => hexToHsv(props.value), [props.value]);21 const [hsv, setHsv] = useState(() => hexToHsv(props.value));
79 const [hsv, setHsv] = useState(initialHsv);
80 const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v);22 const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v);
81 const [hexDraft, setHexDraft] = useState<string | null>(null);23 const [hexDraft, setHexDraft] = useState<string | null>(null);
8224
83 const commitHsv = useCallback(25 const commitHsv = useCallback(
84 (nextHsv: typeof hsv) => {26 (nextHsv: HsvColor) => {
85 setHsv(nextHsv);27 setHsv(nextHsv);
86 onCommit(hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v));28 onCommit(hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v));
87 },29 },
88 [onCommit],30 [onCommit],
89 );31 );
9032
91 const updateFromPlane = useCallback(
92 (event: PointerEvent<HTMLDivElement>) => {
93 const bounds = event.currentTarget.getBoundingClientRect();
94 const saturation = clamp((event.clientX - bounds.left) / bounds.width);
95 const value = 1 - clamp((event.clientY - bounds.top) / bounds.height);
96 commitHsv({ ...hsv, s: saturation, v: value });
97 },
98 [commitHsv, hsv],
99 );
100
101 const updateFromHue = useCallback(
102 (event: PointerEvent<HTMLDivElement>) => {
103 const bounds = event.currentTarget.getBoundingClientRect();
104 commitHsv({ ...hsv, h: clamp((event.clientX - bounds.left) / bounds.width) * 360 });
105 },
106 [commitHsv, hsv],
107 );
108
109 const handlePointerDown = (handler: (event: PointerEvent<HTMLDivElement>) => void) => {
110 return (event: PointerEvent<HTMLDivElement>) => {
111 event.currentTarget.setPointerCapture(event.pointerId);
112 handler(event);
113 };
114 };
115
116 return (33 return (
117 <div className="w-56 bg-popover">34 <div className="w-56 bg-popover">
118 <div35 <ColorSaturationValuePlane
119 className="relative h-36 cursor-crosshair touch-none"36 label="Accent color"
120 style={{37 value={hsv}
121 backgroundColor: `hsl(${hsv.h} 100% 50%)`,38 onChange={commitHsv}
122 backgroundImage:39 variant="edge"
123 "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",40 />
124 }}
125 onPointerDown={handlePointerDown(updateFromPlane)}
126 onPointerMove={(event) => {
127 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
128 updateFromPlane(event);
129 }
130 }}
131 >
132 <span
133 className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.35)]"
134 style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%` }}
135 />
136 </div>
137 <div className="grid gap-3 p-3">41 <div className="grid gap-3 p-3">
138 <div42 <ColorHueSlider
139 className="relative h-3 cursor-pointer touch-none rounded-full"43 label="Accent color hue"
140 style={{44 value={hsv.h}
141 background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",45 onChange={(h) => commitHsv({ ...hsv, h })}
142 }}46 />
143 onPointerDown={handlePointerDown(updateFromHue)}47 <Input
144 onPointerMove={(event) => {48 nativeInput
145 if (event.currentTarget.hasPointerCapture(event.pointerId)) {49 size="sm"
146 updateFromHue(event);
147 }
148 }}
149 >
150 <span
151 className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.35)]"
152 style={{ left: `${(hsv.h / 360) * 100}%`, backgroundColor: currentColor }}
153 />
154 </div>
155 <input
156 value={hexDraft ?? currentColor}50 value={hexDraft ?? currentColor}
157 onChange={(event) => {51 onChange={(event) => {
158 const nextColor = event.currentTarget.value;52 const nextColor = event.currentTarget.value;
...@@ -162,7 +56,7 @@ function ProviderCustomColorPanel(props: {...@@ -162,7 +56,7 @@ function ProviderCustomColorPanel(props: {
162 props.onCommit(nextColor);56 props.onCommit(nextColor);
163 }}57 }}
164 onBlur={() => setHexDraft(null)}58 onBlur={() => setHexDraft(null)}
165 className="h-8 rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground outline-none transition-colors focus:border-ring"59 className="font-mono text-xs"
166 aria-label="Custom hex accent color"60 aria-label="Custom hex accent color"
167 spellCheck={false}61 spellCheck={false}
168 />62 />
apps/web/src/components/settings/ThemeColorPicker.tsx+22-198
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import type { KeyboardEvent, PointerEvent } from "react";
2import { memo, useCallback, useEffect, useRef, useState } from "react";1import { memo, useCallback, useEffect, useRef, useState } from "react";
3import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette";2import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette";
4import { cn } from "../../lib/utils";3import { cn } from "../../lib/utils";
4import { hexToHsv, hsvToHex, type HsvColor } from "../../lib/color";
5import { ColorHueSlider, ColorSaturationValuePlane } from "../ui/color-picker";
5import { Input } from "../ui/input";6import { Input } from "../ui/input";
6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";7import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";8import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -27,16 +28,6 @@ export function getThemeRoleLabel(role: ThemeColorRole): string {...@@ -27,16 +28,6 @@ export function getThemeRoleLabel(role: ThemeColorRole): string {
27 return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase());28 return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase());
28}29}
2930
30type ThemeColorHsv = {
31 h: number;
32 s: number;
33 v: number;
34};
35
36function clampThemeColor(value: number, min = 0, max = 1) {
37 return Math.min(max, Math.max(min, value));
38}
39
40/**31/**
41 * The picker remains an sRGB/hex adapter over the OKLCH palette engine. Alpha32 * The picker remains an sRGB/hex adapter over the OKLCH palette engine. Alpha
42 * is preserved separately and re-attached on commit so adjusting hue or33 * is preserved separately and re-attached on commit so adjusting hue or
...@@ -52,63 +43,6 @@ function normalizeThemePickerColor(value: string): string {...@@ -52,63 +43,6 @@ function normalizeThemePickerColor(value: string): string {
52 return (themeColorToHex(value) ?? "#000000").slice(0, 7);43 return (themeColorToHex(value) ?? "#000000").slice(0, 7);
53}44}
5445
55function themeHexToHsv(hex: string): ThemeColorHsv {
56 const normalized = normalizeThemePickerColor(hex);
57 const numeric = Number.parseInt(normalized.slice(1), 16);
58 const red = ((numeric >> 16) & 255) / 255;
59 const green = ((numeric >> 8) & 255) / 255;
60 const blue = (numeric & 255) / 255;
61 const max = Math.max(red, green, blue);
62 const min = Math.min(red, green, blue);
63 const delta = max - min;
64
65 let hue = 0;
66 if (delta !== 0) {
67 if (max === red) {
68 hue = ((green - blue) / delta) % 6;
69 } else if (max === green) {
70 hue = (blue - red) / delta + 2;
71 } else {
72 hue = (red - green) / delta + 4;
73 }
74 hue *= 60;
75 if (hue < 0) hue += 360;
76 }
77
78 return {
79 h: hue,
80 s: max === 0 ? 0 : delta / max,
81 v: max,
82 };
83}
84
85function themeHsvToHex(hue: number, saturation: number, value: number) {
86 const normalizedHue = ((hue % 360) + 360) % 360;
87 const chroma = value * saturation;
88 const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
89 const match = value - chroma;
90 const [red, green, blue] =
91 normalizedHue < 60
92 ? [chroma, x, 0]
93 : normalizedHue < 120
94 ? [x, chroma, 0]
95 : normalizedHue < 180
96 ? [0, chroma, x]
97 : normalizedHue < 240
98 ? [0, x, chroma]
99 : normalizedHue < 300
100 ? [x, 0, chroma]
101 : [chroma, 0, x];
102
103 return `#${[red, green, blue]
104 .map((channel) =>
105 Math.round((channel + match) * 255)
106 .toString(16)
107 .padStart(2, "0"),
108 )
109 .join("")}`;
110}
111
112function themeHexToRgb(hex: string) {46function themeHexToRgb(hex: string) {
113 const numeric = Number.parseInt(normalizeThemePickerColor(hex).slice(1), 16);47 const numeric = Number.parseInt(normalizeThemePickerColor(hex).slice(1), 16);
114 return [numeric >> 16, (numeric >> 8) & 255, numeric & 255] as const;48 return [numeric >> 16, (numeric >> 8) & 255, numeric & 255] as const;
...@@ -148,12 +82,11 @@ function ThemeColorPickerPanel({...@@ -148,12 +82,11 @@ function ThemeColorPickerPanel({
148}) {82}) {
149 const normalizedValue = normalizeThemePickerColor(value);83 const normalizedValue = normalizeThemePickerColor(value);
150 const alphaSuffix = themePickerAlphaSuffix(value);84 const alphaSuffix = themePickerAlphaSuffix(value);
151 const [hsv, setHsv] = useState(() => themeHexToHsv(normalizedValue));85 const [hsv, setHsv] = useState(() => hexToHsv(normalizedValue));
152 const [hexDraft, setHexDraft] = useState(normalizedValue);86 const [hexDraft, setHexDraft] = useState(normalizedValue);
153 const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue));87 const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue));
154 const [isDragging, setIsDragging] = useState(false);
155 const isEditingTextRef = useRef(false);88 const isEditingTextRef = useRef(false);
156 const currentColor = themeHsvToHex(hsv.h, hsv.s, hsv.v);89 const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v);
157 const currentRgb = themeRgbValue(currentColor);90 const currentRgb = themeRgbValue(currentColor);
15891
159 useEffect(() => {92 useEffect(() => {
...@@ -167,9 +100,9 @@ function ThemeColorPickerPanel({...@@ -167,9 +100,9 @@ function ThemeColorPickerPanel({
167 // Keep the current hue/saturation when the incoming value is just our own100 // Keep the current hue/saturation when the incoming value is just our own
168 // change echoed back; hex → HSV is lossy for greys, white, and black.101 // change echoed back; hex → HSV is lossy for greys, white, and black.
169 setHsv((current) =>102 setHsv((current) =>
170 themeHsvToHex(current.h, current.s, current.v) === normalizedValue103 hsvToHex(current.h, current.s, current.v) === normalizedValue
171 ? current104 ? current
172 : themeHexToHsv(normalizedValue),105 : hexToHsv(normalizedValue),
173 );106 );
174 }, [normalizedValue]);107 }, [normalizedValue]);
175108
...@@ -203,9 +136,9 @@ function ThemeColorPickerPanel({...@@ -203,9 +136,9 @@ function ThemeColorPickerPanel({
203 }, []);136 }, []);
204137
205 const commitHsv = useCallback(138 const commitHsv = useCallback(
206 (nextHsv: ThemeColorHsv) => {139 (nextHsv: HsvColor) => {
207 setHsv(nextHsv);140 setHsv(nextHsv);
208 const nextColor = themeHsvToHex(nextHsv.h, nextHsv.s, nextHsv.v);141 const nextColor = hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v);
209 setHexDraft(nextColor);142 setHexDraft(nextColor);
210 setRgbDraft(themeRgbValue(nextColor));143 setRgbDraft(themeRgbValue(nextColor));
211 scheduleCommit(nextColor + alphaSuffix);144 scheduleCommit(nextColor + alphaSuffix);
...@@ -213,69 +146,10 @@ function ThemeColorPickerPanel({...@@ -213,69 +146,10 @@ function ThemeColorPickerPanel({
213 [alphaSuffix, scheduleCommit],146 [alphaSuffix, scheduleCommit],
214 );147 );
215148
216 const updateFromPlane = useCallback(
217 (event: PointerEvent<HTMLDivElement>) => {
218 const bounds = event.currentTarget.getBoundingClientRect();
219 const saturation = clampThemeColor((event.clientX - bounds.left) / bounds.width);
220 const value = 1 - clampThemeColor((event.clientY - bounds.top) / bounds.height);
221 commitHsv({ ...hsv, s: saturation, v: value });
222 },
223 [commitHsv, hsv],
224 );
225
226 const updateFromHue = useCallback(
227 (event: PointerEvent<HTMLDivElement>) => {
228 const bounds = event.currentTarget.getBoundingClientRect();
229 const hue = clampThemeColor((event.clientX - bounds.left) / bounds.width) * 360;
230 commitHsv({ ...hsv, h: hue });
231 },
232 [commitHsv, hsv],
233 );
234
235 const handleHueKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
236 const step = event.shiftKey ? 10 : 1;
237 const direction = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1;
238 if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return;
239 event.preventDefault();
240 commitHsv({ ...hsv, h: (hsv.h + direction * step + 360) % 360 });
241 };
242
243 const handlePlaneKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
244 if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return;
245 event.preventDefault();
246 const step = event.shiftKey ? 0.1 : 0.02;
247 const nextHsv = { ...hsv };
248 if (event.key === "ArrowLeft") nextHsv.s = clampThemeColor(hsv.s - step);
249 if (event.key === "ArrowRight") nextHsv.s = clampThemeColor(hsv.s + step);
250 if (event.key === "ArrowUp") nextHsv.v = clampThemeColor(hsv.v + step);
251 if (event.key === "ArrowDown") nextHsv.v = clampThemeColor(hsv.v - step);
252 commitHsv(nextHsv);
253 };
254
255 const handlePointerDown = (handler: (event: PointerEvent<HTMLDivElement>) => void) => {
256 return (event: PointerEvent<HTMLDivElement>) => {
257 event.currentTarget.setPointerCapture(event.pointerId);
258 setIsDragging(true);
259 handler(event);
260 };
261 };
262
263 const stopDragging = () => {
264 setIsDragging(false);
265 flushPendingCommit();
266 };
267
268 // Thumbs travel inside the control by half their own size so they never
269 // clip at the extremes; movement only animates for keyboard steps and
270 // click-to-jump, never while dragging.
271 const thumbTransition = isDragging
272 ? undefined
273 : "left 80ms linear, top 80ms linear, background-color 80ms linear";
274
275 const handleHexChange = (nextValue: string) => {149 const handleHexChange = (nextValue: string) => {
276 setHexDraft(nextValue);150 setHexDraft(nextValue);
277 if (!/^#[0-9a-f]{6}$/i.test(nextValue)) return;151 if (!/^#[0-9a-f]{6}$/i.test(nextValue)) return;
278 const nextHsv = themeHexToHsv(nextValue);152 const nextHsv = hexToHsv(nextValue);
279 setHsv(nextHsv);153 setHsv(nextHsv);
280 setRgbDraft(themeRgbValue(nextValue));154 setRgbDraft(themeRgbValue(nextValue));
281 onChange(nextValue.toLowerCase());155 onChange(nextValue.toLowerCase());
...@@ -285,7 +159,7 @@ function ThemeColorPickerPanel({...@@ -285,7 +159,7 @@ function ThemeColorPickerPanel({
285 setRgbDraft(nextValue);159 setRgbDraft(nextValue);
286 const nextColor = themeRgbToHex(nextValue);160 const nextColor = themeRgbToHex(nextValue);
287 if (!nextColor) return;161 if (!nextColor) return;
288 setHsv(themeHexToHsv(nextColor));162 setHsv(hexToHsv(nextColor));
289 setHexDraft(nextColor);163 setHexDraft(nextColor);
290 // RGB cannot express alpha, so a commit keeps the incoming suffix just164 // RGB cannot express alpha, so a commit keeps the incoming suffix just
291 // like the plane and hue controls do.165 // like the plane and hue controls do.
...@@ -305,68 +179,18 @@ function ThemeColorPickerPanel({...@@ -305,68 +179,18 @@ function ThemeColorPickerPanel({
305 />179 />
306 </div>180 </div>
307 <div className="grid gap-3 px-3 pb-3 pt-3">181 <div className="grid gap-3 px-3 pb-3 pt-3">
308 <div182 <ColorSaturationValuePlane
309 aria-label={`${label} saturation and brightness`}183 label={label}
310 aria-valuetext={`saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}184 value={hsv}
311 className="relative h-32 cursor-crosshair touch-none overflow-hidden rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover"185 onChange={commitHsv}
312 role="slider"186 onInteractionEnd={flushPendingCommit}
313 style={{187 />
314 backgroundColor: `hsl(${hsv.h} 100% 50%)`,188 <ColorHueSlider
315 backgroundImage:189 label={`${label} hue`}
316 "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",190 value={hsv.h}
317 }}191 onChange={(h) => commitHsv({ ...hsv, h })}
318 tabIndex={0}192 onInteractionEnd={flushPendingCommit}
319 onKeyDown={handlePlaneKeyDown}193 />
320 onLostPointerCapture={stopDragging}
321 onPointerDown={handlePointerDown(updateFromPlane)}
322 onPointerMove={(event) => {
323 if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromPlane(event);
324 }}
325 onPointerUp={stopDragging}
326 >
327 <span
328 className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4)]"
329 style={{
330 left: `calc(${hsv.s} * (100% - 0.75rem) + 0.375rem)`,
331 top: `calc(${1 - hsv.v} * (100% - 0.75rem) + 0.375rem)`,
332 transition: thumbTransition,
333 }}
334 />
335 </div>
336 <div
337 aria-label={`${label} hue`}
338 aria-valuemax={360}
339 aria-valuemin={0}
340 aria-valuenow={Math.round(hsv.h)}
341 className="relative flex h-6 cursor-pointer touch-none items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover"
342 role="slider"
343 tabIndex={0}
344 onKeyDown={handleHueKeyDown}
345 onLostPointerCapture={stopDragging}
346 onPointerDown={handlePointerDown(updateFromHue)}
347 onPointerMove={(event) => {
348 if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromHue(event);
349 }}
350 onPointerUp={stopDragging}
351 >
352 <span
353 aria-hidden
354 className="h-2.5 w-full rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0_/_12%)]"
355 style={{
356 background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",
357 }}
358 />
359 <span
360 className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4)]"
361 style={{
362 left: `calc(${hsv.h / 360} * (100% - 1rem) + 0.5rem)`,
363 // The ball shows the pure hue so it stays visually anchored to
364 // the track; the header swatch carries the full current color.
365 backgroundColor: `hsl(${hsv.h} 100% 50%)`,
366 transition: thumbTransition,
367 }}
368 />
369 </div>
370 <div className="grid grid-cols-[1fr_1.2fr] gap-2">194 <div className="grid grid-cols-[1fr_1.2fr] gap-2">
371 <label className="grid min-w-0 gap-1">195 <label className="grid min-w-0 gap-1">
372 <span className="px-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">196 <span className="px-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
apps/web/src/components/settings/colorPickers.test.tsx created+256
...@@ -0,0 +1,256 @@
1import { act, type ReactNode, type ReactElement } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5// Render popover content without portals so these tests exercise picker input
6// and persistence behavior without starting a browser.
7vi.mock("../ui/popover", () => ({
8 Popover: ({ children }: { children: ReactNode }) => children,
9 PopoverPopup: ({ children }: { children: ReactNode }) => children,
10 PopoverTrigger: () => null,
11 PopoverClose: () => null,
12}));
13vi.mock("../ui/tooltip", () => ({
14 Tooltip: ({ children }: { children: ReactNode }) => children,
15 TooltipTrigger: ({ render }: { render: ReactElement }) => render,
16 TooltipPopup: () => null,
17}));
18
19import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
20import { ThemeColorField } from "./ThemeColorPicker";
21
22let renderer: ReactTestRenderer | undefined;
23let nextFrameId = 0;
24const frames = new Map<number, FrameRequestCallback>();
25
26beforeEach(() => {
27 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
28 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
29 frames.set(++nextFrameId, callback);
30 return nextFrameId;
31 });
32 vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
33});
34
35afterEach(async () => {
36 await act(async () => renderer?.unmount());
37 renderer = undefined;
38 frames.clear();
39 vi.useRealTimers();
40 vi.unstubAllGlobals();
41});
42
43function slider(label: string) {
44 return renderer!.root.find(
45 (node) =>
46 (node.props.role === "slider" || node.props.type === "range") &&
47 node.props["aria-label"] === label,
48 );
49}
50
51function plane(label: string) {
52 return renderer!.root.findByProps({ role: "group", "aria-label": label });
53}
54
55async function key(label: string, key: string, shiftKey = false) {
56 const preventDefault = vi.fn();
57 await act(async () => slider(label).props.onKeyDown({ key, shiftKey, preventDefault }));
58 return preventDefault;
59}
60
61function pointer(clientX: number, clientY = 0, pointerId = 1) {
62 return {
63 button: 0,
64 clientX,
65 clientY,
66 pointerId,
67 currentTarget: {
68 focus: vi.fn(),
69 setPointerCapture: vi.fn(),
70 getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
71 },
72 };
73}
74
75async function frame() {
76 await act(async () => {
77 const pending = [...frames.values()];
78 frames.clear();
79 for (const callback of pending) callback(0);
80 });
81}
82
83describe("shared color controls in settings", () => {
84 it("adjusts each provider color axis independently and reports the value being changed", async () => {
85 const onCommit = vi.fn();
86 await act(async () => {
87 renderer = create(
88 <ProviderAccentColorPicker displayName="Codex" value="#ff0000" onCommit={onCommit} />,
89 );
90 });
91 const hue = "Accent color hue";
92 const saturation = "Accent color saturation";
93 const brightness = "Accent color brightness";
94 expect(await key(hue, "ArrowLeft")).toHaveBeenCalledOnce();
95 expect(slider(hue).props["aria-valuenow"]).toBe(359);
96 await key(hue, "ArrowRight");
97 expect(onCommit).toHaveBeenLastCalledWith("#ff0000");
98 await key(saturation, "ArrowRight", true);
99 await key(brightness, "ArrowUp", true);
100 expect(onCommit).toHaveBeenLastCalledWith("#ff0000");
101 await key(brightness, "ArrowDown", true);
102 expect(onCommit).toHaveBeenLastCalledWith("#e60000");
103 expect(slider(brightness).props.value).toBe(90);
104 expect(slider(brightness).props["aria-valuetext"]).toBe("90%");
105 expect(slider(saturation).props.value).toBe(100);
106 // Up/Down on saturation must adjust the reported saturation, not brightness.
107 await key(saturation, "ArrowDown", true);
108 expect(onCommit).toHaveBeenLastCalledWith("#e61717");
109 expect(slider(saturation).props.value).toBe(90);
110 expect(slider(saturation).props["aria-valuetext"]).toBe("90%");
111 await key(saturation, "ArrowUp", true);
112 expect(onCommit).toHaveBeenLastCalledWith("#e60000");
113 // Left/Right on brightness must likewise leave saturation unchanged.
114 await key(brightness, "ArrowLeft", true);
115 expect(onCommit).toHaveBeenLastCalledWith("#cc0000");
116 await key(brightness, "ArrowRight", true);
117 expect(onCommit).toHaveBeenLastCalledWith("#e60000");
118 onCommit.mockClear();
119 expect(await key(saturation, "Tab")).not.toHaveBeenCalled();
120 expect(await key(brightness, "Tab")).not.toHaveBeenCalled();
121 expect(onCommit).not.toHaveBeenCalled();
122 });
123
124 it("supports Home/End and clamps each axis without changing the other", async () => {
125 const onCommit = vi.fn();
126 await act(async () => {
127 renderer = create(
128 <ProviderAccentColorPicker displayName="Codex" value="#ff0000" onCommit={onCommit} />,
129 );
130 });
131 const saturation = "Accent color saturation";
132 const brightness = "Accent color brightness";
133 await key(saturation, "Home");
134 await key(saturation, "ArrowLeft");
135 expect(slider(saturation).props.value).toBe(0);
136 expect(onCommit).toHaveBeenLastCalledWith("#ffffff");
137 await key(brightness, "Home");
138 await key(brightness, "ArrowDown");
139 expect(slider(brightness).props.value).toBe(0);
140 expect(onCommit).toHaveBeenLastCalledWith("#000000");
141 await key(saturation, "End");
142 expect(slider(saturation).props.value).toBe(100);
143 expect(slider(brightness).props.value).toBe(0);
144 await key(brightness, "End");
145 await key(brightness, "ArrowUp");
146 expect(onCommit).toHaveBeenLastCalledWith("#ff0000");
147 await key(saturation, "ArrowLeft");
148 expect(slider(saturation).props.value).toBe(98);
149 expect(onCommit).toHaveBeenLastCalledWith("#ff0505");
150 });
151
152 it("persists independent native range changes through theme batching with alpha", async () => {
153 const onChange = vi.fn();
154 await act(async () => {
155 renderer = create(<ThemeColorField role="accent" value="#ff000080" onChange={onChange} />);
156 });
157 const saturation = "Accent color saturation";
158 const brightness = "Accent color brightness";
159 await act(async () =>
160 slider(saturation).props.onChange({ currentTarget: { valueAsNumber: 50 } }),
161 );
162 await act(async () =>
163 slider(brightness).props.onChange({ currentTarget: { valueAsNumber: 50 } }),
164 );
165 expect(onChange).not.toHaveBeenCalled();
166 expect(frames.size).toBe(1);
167 await frame();
168 expect(onChange).toHaveBeenCalledExactlyOnceWith("accent", "#80404080");
169 await key(brightness, "ArrowDown", true);
170 await frame();
171 expect(onChange).toHaveBeenLastCalledWith("accent", "#66333380");
172 expect(slider(saturation).props.value).toBe(50);
173 });
174
175 it("batches theme drag updates and flushes the final color with alpha on pointer release", async () => {
176 const onChange = vi.fn();
177 await act(async () => {
178 renderer = create(<ThemeColorField role="accent" value="#ff000080" onChange={onChange} />);
179 });
180 const hue = "Accent color hue";
181 await act(async () => slider(hue).props.onPointerDown(pointer(25)));
182 await act(async () => slider(hue).props.onPointerMove(pointer(50)));
183 expect(onChange).not.toHaveBeenCalled();
184 expect(frames.size).toBe(1);
185 // A second pointer cannot change or end the active drag.
186 await act(async () => slider(hue).props.onPointerMove(pointer(75, 0, 2)));
187 await act(async () => slider(hue).props.onPointerUp(pointer(75, 0, 2)));
188 expect(onChange).not.toHaveBeenCalled();
189 await act(async () => slider(hue).props.onPointerUp(pointer(50)));
190 expect(onChange).toHaveBeenCalledExactlyOnceWith("accent", "#00ffff80");
191 await act(async () => slider(hue).props.onLostPointerCapture(pointer(50)));
192 await frame();
193 expect(onChange).toHaveBeenCalledTimes(1);
194 });
195
196 it("clamps out-of-bounds drags and flushes theme changes on cancellation and unmount", async () => {
197 const onChange = vi.fn();
198 await act(async () => {
199 renderer = create(<ThemeColorField role="accent" value="#ff000080" onChange={onChange} />);
200 });
201 const planeLabel = "Accent color saturation and brightness";
202 await act(async () => plane(planeLabel).props.onPointerDown(pointer(-50, -50)));
203 await act(async () => plane(planeLabel).props.onPointerCancel(pointer(-50, -50)));
204 expect(onChange).toHaveBeenLastCalledWith("accent", "#ffffff80");
205 await act(async () => plane(planeLabel).props.onPointerDown(pointer(150, 150)));
206 await act(async () => renderer!.unmount());
207 renderer = undefined;
208 expect(onChange).toHaveBeenLastCalledWith("accent", "#00000080");
209 expect(frames.size).toBe(0);
210 });
211
212 it("preserves the selected hue when a grey color is echoed back from theme settings", async () => {
213 const onChange = vi.fn();
214 const render = (value: string) => (
215 <ThemeColorField role="accent" value={value} onChange={onChange} />
216 );
217 await act(async () => {
218 renderer = create(render("#ff0000"));
219 });
220 const hue = "Accent color hue";
221 const planeLabel = "Accent color saturation and brightness";
222 await key(hue, "ArrowRight", true);
223 await act(async () => plane(planeLabel).props.onPointerDown(pointer(0, 0)));
224 await frame();
225 expect(onChange).toHaveBeenLastCalledWith("accent", "#ffffff");
226 await act(async () => renderer!.update(render("#ffffff")));
227 expect(slider(hue).props["aria-valuenow"]).toBe(10);
228 });
229
230 it("keeps provider debounce at the consumer and commits pending color on unmount", async () => {
231 vi.useFakeTimers();
232 const onCommit = vi.fn();
233 await act(async () => {
234 renderer = create(
235 <ProviderAccentColorPicker
236 displayName="Codex"
237 value="#ff0000"
238 onCommit={onCommit}
239 commitDelayMs={250}
240 />,
241 );
242 });
243 const hue = "Accent color hue";
244 await key(hue, "ArrowRight", true);
245 await key(hue, "ArrowRight", true);
246 expect(onCommit).not.toHaveBeenCalled();
247 await act(async () => vi.advanceTimersByTime(250));
248 expect(onCommit).toHaveBeenCalledExactlyOnceWith("#ff5500");
249 await key(hue, "ArrowRight", true);
250 await act(async () => renderer!.unmount());
251 renderer = undefined;
252 expect(onCommit).toHaveBeenLastCalledWith("#ff8000");
253 await act(async () => vi.advanceTimersByTime(250));
254 expect(onCommit).toHaveBeenCalledTimes(2);
255 });
256});
apps/web/src/components/ui/color-picker.tsx created+225
...@@ -0,0 +1,225 @@
1import {
2 useId,
3 useRef,
4 useState,
5 type KeyboardEvent,
6 type PointerEvent,
7 type RefObject,
8} from "react";
9
10import type { HsvColor } from "../../lib/color";
11import { cn } from "../../lib/utils";
12
13function clamp(value: number) {
14 return Math.min(1, Math.max(0, value));
15}
16
17/** Keep pointer capture and drag completion consistent across color controls. */
18function useColorDrag(
19 update: (event: PointerEvent<HTMLDivElement>) => void,
20 onInteractionEnd?: () => void,
21 focusTarget?: RefObject<HTMLElement | null>,
22) {
23 const [isDragging, setIsDragging] = useState(false);
24 const pointerId = useRef<number | null>(null);
25 const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
26 if (pointerId.current !== event.pointerId) return;
27 pointerId.current = null;
28 setIsDragging(false);
29 onInteractionEnd?.();
30 };
31 return {
32 // The thumb stays inside the control at its extremes, and only animates
33 // keyboard adjustments, never continuous pointer movement.
34 thumbTransition: isDragging
35 ? undefined
36 : "left 80ms linear, top 80ms linear, background-color 80ms linear",
37 handlers: {
38 onPointerDown(event: PointerEvent<HTMLDivElement>) {
39 if (pointerId.current !== null || event.button !== 0) return;
40 pointerId.current = event.pointerId;
41 (focusTarget?.current ?? event.currentTarget).focus({ preventScroll: true });
42 event.currentTarget.setPointerCapture(event.pointerId);
43 setIsDragging(true);
44 update(event);
45 },
46 onPointerMove(event: PointerEvent<HTMLDivElement>) {
47 if (pointerId.current === event.pointerId) update(event);
48 },
49 onPointerUp: stopDragging,
50 onPointerCancel: stopDragging,
51 onLostPointerCapture: stopDragging,
52 },
53 };
54}
55
56type ColorControlProps<T> = {
57 label: string;
58 value: T;
59 onChange: (value: T) => void;
60 /** Flush pending consumer updates when pointer interaction finishes. */
61 onInteractionEnd?: () => void;
62 className?: string;
63};
64
65export function ColorSaturationValuePlane({
66 label,
67 value,
68 onChange,
69 onInteractionEnd,
70 className,
71 variant = "inset",
72}: ColorControlProps<HsvColor> & { variant?: "inset" | "edge" }) {
73 const instructionsId = useId();
74 const saturationRef = useRef<HTMLInputElement>(null);
75 const { handlers, thumbTransition } = useColorDrag(
76 (event) => {
77 const bounds = event.currentTarget.getBoundingClientRect();
78 if (!bounds.width || !bounds.height) return;
79 onChange({
80 ...value,
81 s: clamp((event.clientX - bounds.left) / bounds.width),
82 v: 1 - clamp((event.clientY - bounds.top) / bounds.height),
83 });
84 },
85 onInteractionEnd,
86 saturationRef,
87 );
88
89 const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>, axis: "s" | "v") => {
90 const step = event.shiftKey ? 0.1 : 0.02;
91 let nextValue: number;
92 switch (event.key) {
93 case "ArrowRight":
94 case "ArrowUp":
95 nextValue = clamp(value[axis] + step);
96 break;
97 case "ArrowLeft":
98 case "ArrowDown":
99 nextValue = clamp(value[axis] - step);
100 break;
101 case "Home":
102 nextValue = 0;
103 break;
104 case "End":
105 nextValue = 1;
106 break;
107 default:
108 return;
109 }
110 event.preventDefault();
111 onChange({ ...value, [axis]: nextValue });
112 };
113
114 return (
115 <div
116 aria-label={`${label} saturation and brightness`}
117 role="group"
118 className={cn(
119 "relative cursor-crosshair touch-none overflow-hidden bg-[linear-gradient(to_top,#000,transparent),linear-gradient(to_right,#fff,transparent)] has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-popover",
120 variant === "edge" ? "h-36 rounded-none" : "h-32 rounded-lg",
121 className,
122 )}
123 style={{
124 backgroundColor: `hsl(${value.h} 100% 50%)`,
125 }}
126 {...handlers}
127 >
128 <span id={instructionsId} className="sr-only">
129 Use arrow keys to adjust the focused value. Hold Shift for larger steps. Use Home and End
130 for the minimum and maximum. Press Tab to move between saturation and brightness.
131 </span>
132 {(
133 [
134 ["s", "Saturation"],
135 ["v", "Brightness"],
136 ] as const
137 ).map(([axis, axisLabel]) => (
138 <label key={axis} className="contents">
139 <input
140 ref={axis === "s" ? saturationRef : undefined}
141 type="range"
142 min={0}
143 max={100}
144 step="any"
145 value={value[axis] * 100}
146 aria-label={`${label} ${axisLabel.toLowerCase()}`}
147 aria-describedby={instructionsId}
148 aria-valuetext={`${Math.round(value[axis] * 100)}%`}
149 className="peer sr-only"
150 onKeyDown={(event) => handleKeyDown(event, axis)}
151 onChange={(event) =>
152 onChange({ ...value, [axis]: event.currentTarget.valueAsNumber / 100 })
153 }
154 />
155 <span
156 aria-hidden
157 className="pointer-events-none invisible absolute bottom-2 left-2 z-10 rounded bg-popover px-1.5 py-0.5 text-xs text-popover-foreground peer-focus-visible:visible"
158 >
159 {axisLabel} {Math.round(value[axis] * 100)}%
160 </span>
161 </label>
162 ))}
163 <span
164 className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4)]"
165 style={{
166 left: `calc(${value.s} * (100% - 0.75rem) + 0.375rem)`,
167 top: `calc(${1 - value.v} * (100% - 0.75rem) + 0.375rem)`,
168 transition: thumbTransition,
169 }}
170 />
171 </div>
172 );
173}
174
175export function ColorHueSlider({
176 label,
177 value,
178 onChange,
179 onInteractionEnd,
180 className,
181}: ColorControlProps<number>) {
182 const { handlers, thumbTransition } = useColorDrag((event) => {
183 const bounds = event.currentTarget.getBoundingClientRect();
184 if (!bounds.width) return;
185 onChange(clamp((event.clientX - bounds.left) / bounds.width) * 360);
186 }, onInteractionEnd);
187
188 const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
189 if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return;
190 event.preventDefault();
191 const step = event.shiftKey ? 10 : 1;
192 const direction = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1;
193 onChange((value + direction * step + 360) % 360);
194 };
195
196 return (
197 <div
198 aria-label={label}
199 aria-valuemax={360}
200 aria-valuemin={0}
201 aria-valuenow={Math.round(value)}
202 className={cn(
203 "relative flex h-6 cursor-pointer touch-none items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-popover",
204 className,
205 )}
206 role="slider"
207 tabIndex={0}
208 onKeyDown={handleKeyDown}
209 {...handlers}
210 >
211 <span
212 aria-hidden
213 className="h-2.5 w-full rounded-full bg-[linear-gradient(to_right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)] shadow-[inset_0_0_0_1px_rgb(0_0_0_/_12%)]"
214 />
215 <span
216 className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.4)]"
217 style={{
218 left: `calc(${value / 360} * (100% - 1rem) + 0.5rem)`,
219 backgroundColor: `hsl(${value} 100% 50%)`,
220 transition: thumbTransition,
221 }}
222 />
223 </div>
224 );
225}
apps/web/src/lib/color.test.ts created+24
...@@ -0,0 +1,24 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { hexToHsv, hsvToHex } from "./color";
4
5describe("picker color conversion", () => {
6 it.each(["#000000", "#ffffff", "#808080", "#ff0000", "#00ff00", "#0000ff", "#2563eb"])(
7 "round trips %s through HSV without changing the persisted color",
8 (hex) => {
9 const { h, s, v } = hexToHsv(hex);
10 expect(hsvToHex(h, s, v)).toBe(hex);
11 },
12 );
13
14 it("wraps hue at the slider boundary and across keyboard steps", () => {
15 expect(hsvToHex(360, 1, 1)).toBe("#ff0000");
16 expect(hsvToHex(720, 1, 1)).toBe("#ff0000");
17 expect(hsvToHex(-60, 1, 1)).toBe("#ff00ff");
18 });
19
20 it("represents black and greys without undefined saturation", () => {
21 expect(hexToHsv("#000000")).toEqual({ h: 0, s: 0, v: 0 });
22 expect(hexToHsv("#ffffff")).toEqual({ h: 0, s: 0, v: 1 });
23 });
24});
apps/web/src/lib/color.ts created+62
...@@ -0,0 +1,62 @@
1export type HsvColor = {
2 h: number;
3 s: number;
4 v: number;
5};
6
7/** Convert a normalized six-digit sRGB hex color to HSV. */
8export function hexToHsv(hex: string): HsvColor {
9 const numeric = Number.parseInt(hex.slice(1), 16);
10 const red = ((numeric >> 16) & 255) / 255;
11 const green = ((numeric >> 8) & 255) / 255;
12 const blue = (numeric & 255) / 255;
13 const max = Math.max(red, green, blue);
14 const min = Math.min(red, green, blue);
15 const delta = max - min;
16
17 let hue = 0;
18 if (delta !== 0) {
19 if (max === red) {
20 hue = ((green - blue) / delta) % 6;
21 } else if (max === green) {
22 hue = (blue - red) / delta + 2;
23 } else {
24 hue = (red - green) / delta + 4;
25 }
26 hue *= 60;
27 if (hue < 0) hue += 360;
28 }
29
30 return {
31 h: hue,
32 s: max === 0 ? 0 : delta / max,
33 v: max,
34 };
35}
36
37export function hsvToHex(hue: number, saturation: number, value: number) {
38 const normalizedHue = ((hue % 360) + 360) % 360;
39 const chroma = value * saturation;
40 const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
41 const match = value - chroma;
42 const [red, green, blue] =
43 normalizedHue < 60
44 ? [chroma, x, 0]
45 : normalizedHue < 120
46 ? [x, chroma, 0]
47 : normalizedHue < 180
48 ? [0, chroma, x]
49 : normalizedHue < 240
50 ? [0, x, chroma]
51 : normalizedHue < 300
52 ? [x, 0, chroma]
53 : [chroma, 0, x];
54
55 return `#${[red, green, blue]
56 .map((channel) =>
57 Math.round((channel + match) * 255)
58 .toString(16)
59 .padStart(2, "0"),
60 )
61 .join("")}`;
62}