1import { memo, useCallback, useEffect, useRef, useState } from "react";
2import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette";
3import { cn } from "../../lib/utils";
4import { hexToHsv, hsvToHex, type HsvColor } from "../../lib/color";
5import { ColorHueSlider, ColorSaturationValuePlane } from "../ui/color-picker";
6import { Input } from "../ui/input";
7import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
8import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
9export function getThemeRoleLabel(role: ThemeColorRole): string {
10 const labels: Partial<Record<ThemeColorRole, string>> = {
11 canvas: "Background",
12 toolbar: "Toolbar background",
13 toolbarForeground: "Toolbar text",
14 toolbarBorder: "Toolbar border",
15 toolbarControl: "Toolbar control",
16 toolbarControlForeground: "Toolbar control text",
17 toolbarControlHover: "Toolbar control hover",
18 accent: "Accent color",
19 errorForeground: "Error text",
20 errorSurface: "Error background",
21 warningForeground: "Warning text",
22 warningSurface: "Warning background",
23 updateForeground: "Update text",
24 updateSurface: "Update background",
25 };
26 const label = labels[role];
27 if (label) return label;
28 return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase());
29}
30
31/**
32 * The picker remains an sRGB/hex adapter over the OKLCH palette engine. Alpha
33 * is preserved separately and re-attached on commit so adjusting hue or
34 * brightness cannot change transparency.
35 */
36function themePickerAlphaSuffix(value: string): string {
37 const normalized = themeColorToHex(value) ?? "";
38 const alpha = normalized.length === 9 ? normalized.slice(7) : "";
39 return alpha === "ff" ? "" : alpha;
40}
41
42function normalizeThemePickerColor(value: string): string {
43 return (themeColorToHex(value) ?? "#000000").slice(0, 7);
44}
45
46function themeHexToRgb(hex: string) {
47 const numeric = Number.parseInt(normalizeThemePickerColor(hex).slice(1), 16);
48 return [numeric >> 16, (numeric >> 8) & 255, numeric & 255] as const;
49}
50
51function themeRgbToHex(value: string): string | null {
52 const normalized = value
53 .trim()
54 .replace(/^rgb\(\s*/i, "")
55 .replace(/\s*\)$/, "");
56 const channels = normalized
57 .split(/[,\s]+/)
58 .filter(Boolean)
59 .map(Number);
60 if (
61 channels.length !== 3 ||
62 channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)
63 ) {
64 return null;
65 }
66
67 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
68}
69
70function themeRgbValue(hex: string) {
71 return themeHexToRgb(hex).join(", ");
72}
73
74function ThemeColorPickerPanel({
75 label,
76 value,
77 onChange,
78}: {
79 label: string;
80 value: string;
81 onChange: (value: string) => void;
82}) {
83 const normalizedValue = normalizeThemePickerColor(value);
84 const alphaSuffix = themePickerAlphaSuffix(value);
85 const [hsv, setHsv] = useState(() => hexToHsv(normalizedValue));
86 const [hexDraft, setHexDraft] = useState(normalizedValue);
87 const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue));
88 const isEditingTextRef = useRef(false);
89 const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v);
90 const currentRgb = themeRgbValue(currentColor);
91
92 useEffect(() => {
93 // While a text field is focused, the incoming value may be the guided
94 // editor's readability-adjusted echo of what is being typed; rewriting the
95 // draft would fight the keystrokes. The swatch still tracks via hsv.
96 if (!isEditingTextRef.current) {
97 setHexDraft(normalizedValue);
98 setRgbDraft(themeRgbValue(normalizedValue));
99 }
100 // Keep the current hue/saturation when the incoming value is just our own
101 // change echoed back; hex → HSV is lossy for greys, white, and black.
102 setHsv((current) =>
103 hsvToHex(current.h, current.s, current.v) === normalizedValue
104 ? current
105 : hexToHsv(normalizedValue),
106 );
107 }, [normalizedValue]);
108
109 // Local state updates immediately for a smooth thumb; the parent commit
110 // (which can regenerate a whole guided palette) is batched to one call per
111 // animation frame.
112 const onChangeRef = useRef(onChange);
113 onChangeRef.current = onChange;
114 const pendingCommitRef = useRef<string | null>(null);
115 const commitFrameRef = useRef<number | null>(null);
116 // The final drag frame must not be lost when the popover closes or the
117 // pointer lifts before the animation frame fires.
118 const flushPendingCommit = useCallback(() => {
119 if (commitFrameRef.current !== null) {
120 cancelAnimationFrame(commitFrameRef.current);
121 commitFrameRef.current = null;
122 }
123 const pending = pendingCommitRef.current;
124 pendingCommitRef.current = null;
125 if (pending !== null) onChangeRef.current(pending);
126 }, []);
127 useEffect(() => () => flushPendingCommit(), [flushPendingCommit]);
128 const scheduleCommit = useCallback((color: string) => {
129 pendingCommitRef.current = color;
130 commitFrameRef.current ??= requestAnimationFrame(() => {
131 commitFrameRef.current = null;
132 const pending = pendingCommitRef.current;
133 pendingCommitRef.current = null;
134 if (pending !== null) onChangeRef.current(pending);
135 });
136 }, []);
137
138 const commitHsv = useCallback(
139 (nextHsv: HsvColor) => {
140 setHsv(nextHsv);
141 const nextColor = hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v);
142 setHexDraft(nextColor);
143 setRgbDraft(themeRgbValue(nextColor));
144 scheduleCommit(nextColor + alphaSuffix);
145 },
146 [alphaSuffix, scheduleCommit],
147 );
148
149 const handleHexChange = (nextValue: string) => {
150 setHexDraft(nextValue);
151 if (!/^#[0-9a-f]{6}$/i.test(nextValue)) return;
152 const nextHsv = hexToHsv(nextValue);
153 setHsv(nextHsv);
154 setRgbDraft(themeRgbValue(nextValue));
155 onChange(nextValue.toLowerCase());
156 };
157
158 const handleRgbChange = (nextValue: string) => {
159 setRgbDraft(nextValue);
160 const nextColor = themeRgbToHex(nextValue);
161 if (!nextColor) return;
162 setHsv(hexToHsv(nextColor));
163 setHexDraft(nextColor);
164 // RGB cannot express alpha, so a commit keeps the incoming suffix just
165 // like the plane and hue controls do.
166 onChange(nextColor + alphaSuffix);
167 };
168
169 return (
170 <div className="w-72 bg-popover">
171 <div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
172 <div className="min-w-0">
173 <p className="truncate text-xs font-semibold text-foreground">{label}</p>
174 <p className="text-2xs text-muted-foreground">Choose a color</p>
175 </div>
176 <span
177 className="size-7 shrink-0 rounded-full shadow-sm"
178 style={{ backgroundColor: currentColor }}
179 />
180 </div>
181 <div className="grid gap-3 px-3 pb-3 pt-3">
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 />
194 <div className="grid grid-cols-[1fr_1.2fr] gap-2">
195 <label className="grid min-w-0 gap-1">
196 <span className="px-1 text-3xs font-semibold uppercase tracking-widest text-muted-foreground">
197 HEX
198 </span>
199 <span className="flex min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2 focus-within:border-ring">
200 <span
201 className="size-3.5 shrink-0 rounded-full"
202 style={{ backgroundColor: currentColor }}
203 />
204 <input
205 aria-label={`${label} picker hex value`}
206 className="h-8 min-w-0 flex-1 bg-transparent font-mono text-xs text-foreground outline-none"
207 onBlur={() => {
208 isEditingTextRef.current = false;
209 setHexDraft(currentColor);
210 setRgbDraft(currentRgb);
211 }}
212 onChange={(event) => handleHexChange(event.currentTarget.value)}
213 onFocus={() => {
214 isEditingTextRef.current = true;
215 }}
216 spellCheck={false}
217 value={hexDraft}
218 />
219 </span>
220 </label>
221 <label className="grid min-w-0 gap-1">
222 <span className="px-1 text-3xs font-semibold uppercase tracking-widest text-muted-foreground">
223 RGB
224 </span>
225 <span className="flex min-w-0 items-center rounded-lg border border-input bg-background px-2 focus-within:border-ring">
226 <input
227 aria-label={`${label} picker RGB value`}
228 className="h-8 min-w-0 flex-1 bg-transparent font-mono text-xs text-foreground outline-none"
229 onBlur={() => {
230 isEditingTextRef.current = false;
231 setHexDraft(currentColor);
232 setRgbDraft(currentRgb);
233 }}
234 onChange={(event) => handleRgbChange(event.currentTarget.value)}
235 onFocus={() => {
236 isEditingTextRef.current = true;
237 }}
238 spellCheck={false}
239 value={rgbDraft}
240 />
241 </span>
242 </label>
243 </div>
244 </div>
245 </div>
246 );
247}
248
249function ThemeColorPicker({
250 label,
251 value,
252 onChange,
253 onInteract,
254}: {
255 label: string;
256 value: string;
257 onChange: (value: string) => void;
258 onInteract?: () => void;
259}) {
260 return (
261 <Popover>
262 <Tooltip>
263 <TooltipTrigger
264 render={
265 <PopoverTrigger
266 render={
267 <button
268 aria-label={`Choose ${label} color`}
269 className="relative flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full border border-foreground/30 transition-transform hover:scale-105 focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring"
270 onFocus={onInteract}
271 onPointerDown={onInteract}
272 type="button"
273 >
274 <span
275 className="absolute inset-0 rounded-full shadow-sm"
276 style={{ backgroundColor: value }}
277 />
278 </button>
279 }
280 />
281 }
282 />
283 <TooltipPopup side="top">{`Choose ${label} color`}</TooltipPopup>
284 </Tooltip>
285 <PopoverPopup
286 align="end"
287 padding="none"
288 data-theme-editor-panel=""
289 side="bottom"
290 sideOffset={10}
291 >
292 <ThemeColorPickerPanel label={label} onChange={onChange} value={value} />
293 </PopoverPopup>
294 </Popover>
295 );
296}
297
298export const ThemeColorField = memo(function ThemeColorField({
299 role,
300 value,
301 onChange,
302 onSelect,
303 onToggleSelected,
304 selected = false,
305 label: customLabel,
306}: {
307 role: ThemeColorRole;
308 value: string;
309 onChange: (role: ThemeColorRole, value: string) => void;
310 onSelect?: (role: ThemeColorRole) => void;
311 onToggleSelected?: (role: ThemeColorRole) => void;
312 selected?: boolean;
313 label?: string;
314}) {
315 const label = customLabel ?? getThemeRoleLabel(role);
316 const isColorValue = isThemeColor(value);
317 const swatchValue = isColorValue ? value : "#000000";
318 const editorValue = value.trim().toLowerCase().startsWith("oklch(")
319 ? (themeColorToHex(value) ?? value)
320 : value;
321
322 return (
323 <div
324 className={cn(
325 "flex min-h-11 min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 transition-[background-color,box-shadow]",
326 selected && "bg-accent/60 inset-ring inset-ring-ring",
327 )}
328 data-theme-color-role={role}
329 >
330 <Tooltip>
331 <TooltipTrigger
332 render={
333 <button
334 aria-label={`${selected ? "Hide" : "Show"} ${label} usage`}
335 aria-pressed={selected}
336 className="flex min-w-0 flex-1 cursor-pointer items-center rounded-md text-left text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
337 onClick={() => onToggleSelected?.(role)}
338 type="button"
339 >
340 <span className="min-w-0 flex-1 truncate">{label}</span>
341 </button>
342 }
343 />
344 <TooltipPopup side="top">{`${selected ? "Hide" : "Show"} where ${label} is used`}</TooltipPopup>
345 </Tooltip>
346 <div className="ml-auto flex shrink-0 items-center gap-2">
347 <ThemeColorPicker
348 label={label}
349 onChange={(nextValue) => onChange(role, nextValue)}
350 onInteract={() => onSelect?.(role)}
351 value={swatchValue}
352 />
353 <Input
354 aria-invalid={!isColorValue}
355 aria-label={`${label} hex value`}
356 className="w-28 shrink-0"
357 font="mono"
358 id={`${role}-hex`}
359 nativeInput
360 onChange={(event) => onChange(role, event.currentTarget.value)}
361 onFocus={() => onSelect?.(role)}
362 onPointerDown={() => onSelect?.(role)}
363 size="compact"
364 value={editorValue}
365 />
366 </div>
367 </div>
368 );
369});