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";
2121import { RadioGroup } from "../ui/radio-group";
2222import { toastManager } from "../ui/toast";
2323import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
24import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
2425import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
2526import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard";
2627import {
......@@ -338,12 +339,11 @@ export function AddProviderInstanceDialog({
338339 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
339340 <span className="text-xs font-medium text-foreground">Accent color</span>
340341 <div className="flex min-w-0 flex-wrap items-center gap-2">
341 <input
342 type="color"
343 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
344 onChange={(event) => setAccentColor(event.target.value)}
345 aria-label="Provider instance accent color"
346 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
342 <ProviderAccentColorPicker
343 displayName={label || driverOption.label}
344 value={accentColor || undefined}
345 onCommit={setAccentColor}
346 layout="inline"
347347 />
348348 <div className="flex flex-wrap gap-1.5">
349349 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
apps/web/src/components/settings/ProviderAccentColorPicker.tsx+21-127
......@@ -1,8 +1,11 @@
11"use client";
22
33import { 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";
69import { Button } from "../ui/button";
710import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
811import { normalizeProviderAccentColor } from "../../providerInstances";
......@@ -10,149 +13,40 @@ import { cn } from "../../lib/utils";
1013
1114const 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
7316function ProviderCustomColorPanel(props: {
7417 readonly value: string;
7518 readonly onCommit: (value: string) => void;
7619}) {
7720 const { onCommit } = props;
78 const initialHsv = useMemo(() => hexToHsv(props.value), [props.value]);
79 const [hsv, setHsv] = useState(initialHsv);
21 const [hsv, setHsv] = useState(() => hexToHsv(props.value));
8022 const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v);
8123 const [hexDraft, setHexDraft] = useState<string | null>(null);
8224
8325 const commitHsv = useCallback(
84 (nextHsv: typeof hsv) => {
26 (nextHsv: HsvColor) => {
8527 setHsv(nextHsv);
8628 onCommit(hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v));
8729 },
8830 [onCommit],
8931 );
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
11633 return (
11734 <div className="w-56 bg-popover">
118 <div
119 className="relative h-36 cursor-crosshair touch-none"
120 style={{
121 backgroundColor: `hsl(${hsv.h} 100% 50%)`,
122 backgroundImage:
123 "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
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>
35 <ColorSaturationValuePlane
36 label="Accent color"
37 value={hsv}
38 onChange={commitHsv}
39 variant="edge"
40 />
13741 <div className="grid gap-3 p-3">
138 <div
139 className="relative h-3 cursor-pointer touch-none rounded-full"
140 style={{
141 background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",
142 }}
143 onPointerDown={handlePointerDown(updateFromHue)}
144 onPointerMove={(event) => {
145 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
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
42 <ColorHueSlider
43 label="Accent color hue"
44 value={hsv.h}
45 onChange={(h) => commitHsv({ ...hsv, h })}
46 />
47 <Input
48 nativeInput
49 size="sm"
15650 value={hexDraft ?? currentColor}
15751 onChange={(event) => {
15852 const nextColor = event.currentTarget.value;
......@@ -162,7 +56,7 @@ function ProviderCustomColorPanel(props: {
16256 props.onCommit(nextColor);
16357 }}
16458 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"
16660 aria-label="Custom hex accent color"
16761 spellCheck={false}
16862 />
apps/web/src/components/settings/ThemeColorPicker.tsx+22-198
......@@ -1,7 +1,8 @@
1import type { KeyboardEvent, PointerEvent } from "react";
21import { memo, useCallback, useEffect, useRef, useState } from "react";
32import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette";
43import { cn } from "../../lib/utils";
4import { hexToHsv, hsvToHex, type HsvColor } from "../../lib/color";
5import { ColorHueSlider, ColorSaturationValuePlane } from "../ui/color-picker";
56import { Input } from "../ui/input";
67import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
78import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -27,16 +28,6 @@ export function getThemeRoleLabel(role: ThemeColorRole): string {
2728 return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase());
2829}
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
4031/**
4132 * The picker remains an sRGB/hex adapter over the OKLCH palette engine. Alpha
4233 * is preserved separately and re-attached on commit so adjusting hue or
......@@ -52,63 +43,6 @@ function normalizeThemePickerColor(value: string): string {
5243 return (themeColorToHex(value) ?? "#000000").slice(0, 7);
5344}
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
11246function themeHexToRgb(hex: string) {
11347 const numeric = Number.parseInt(normalizeThemePickerColor(hex).slice(1), 16);
11448 return [numeric >> 16, (numeric >> 8) & 255, numeric & 255] as const;
......@@ -148,12 +82,11 @@ function ThemeColorPickerPanel({
14882}) {
14983 const normalizedValue = normalizeThemePickerColor(value);
15084 const alphaSuffix = themePickerAlphaSuffix(value);
151 const [hsv, setHsv] = useState(() => themeHexToHsv(normalizedValue));
85 const [hsv, setHsv] = useState(() => hexToHsv(normalizedValue));
15286 const [hexDraft, setHexDraft] = useState(normalizedValue);
15387 const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue));
154 const [isDragging, setIsDragging] = useState(false);
15588 const isEditingTextRef = useRef(false);
156 const currentColor = themeHsvToHex(hsv.h, hsv.s, hsv.v);
89 const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v);
15790 const currentRgb = themeRgbValue(currentColor);
15891
15992 useEffect(() => {
......@@ -167,9 +100,9 @@ function ThemeColorPickerPanel({
167100 // Keep the current hue/saturation when the incoming value is just our own
168101 // change echoed back; hex → HSV is lossy for greys, white, and black.
169102 setHsv((current) =>
170 themeHsvToHex(current.h, current.s, current.v) === normalizedValue
103 hsvToHex(current.h, current.s, current.v) === normalizedValue
171104 ? current
172 : themeHexToHsv(normalizedValue),
105 : hexToHsv(normalizedValue),
173106 );
174107 }, [normalizedValue]);
175108
......@@ -203,9 +136,9 @@ function ThemeColorPickerPanel({
203136 }, []);
204137
205138 const commitHsv = useCallback(
206 (nextHsv: ThemeColorHsv) => {
139 (nextHsv: HsvColor) => {
207140 setHsv(nextHsv);
208 const nextColor = themeHsvToHex(nextHsv.h, nextHsv.s, nextHsv.v);
141 const nextColor = hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v);
209142 setHexDraft(nextColor);
210143 setRgbDraft(themeRgbValue(nextColor));
211144 scheduleCommit(nextColor + alphaSuffix);
......@@ -213,69 +146,10 @@ function ThemeColorPickerPanel({
213146 [alphaSuffix, scheduleCommit],
214147 );
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
275149 const handleHexChange = (nextValue: string) => {
276150 setHexDraft(nextValue);
277151 if (!/^#[0-9a-f]{6}$/i.test(nextValue)) return;
278 const nextHsv = themeHexToHsv(nextValue);
152 const nextHsv = hexToHsv(nextValue);
279153 setHsv(nextHsv);
280154 setRgbDraft(themeRgbValue(nextValue));
281155 onChange(nextValue.toLowerCase());
......@@ -285,7 +159,7 @@ function ThemeColorPickerPanel({
285159 setRgbDraft(nextValue);
286160 const nextColor = themeRgbToHex(nextValue);
287161 if (!nextColor) return;
288 setHsv(themeHexToHsv(nextColor));
162 setHsv(hexToHsv(nextColor));
289163 setHexDraft(nextColor);
290164 // RGB cannot express alpha, so a commit keeps the incoming suffix just
291165 // like the plane and hue controls do.
......@@ -305,68 +179,18 @@ function ThemeColorPickerPanel({
305179 />
306180 </div>
307181 <div className="grid gap-3 px-3 pb-3 pt-3">
308 <div
309 aria-label={`${label} saturation and brightness`}
310 aria-valuetext={`saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
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"
312 role="slider"
313 style={{
314 backgroundColor: `hsl(${hsv.h} 100% 50%)`,
315 backgroundImage:
316 "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
317 }}
318 tabIndex={0}
319 onKeyDown={handlePlaneKeyDown}
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>
182 <ColorSaturationValuePlane
183 label={label}
184 value={hsv}
185 onChange={commitHsv}
186 onInteractionEnd={flushPendingCommit}
187 />
188 <ColorHueSlider
189 label={`${label} hue`}
190 value={hsv.h}
191 onChange={(h) => commitHsv({ ...hsv, h })}
192 onInteractionEnd={flushPendingCommit}
193 />
370194 <div className="grid grid-cols-[1fr_1.2fr] gap-2">
371195 <label className="grid min-w-0 gap-1">
372196 <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}