| author | |
| committer | |
| log | 994654198d5820b6fab9ee09063646a8b6fd8e22 |
| tree | 8613e48f566451da34bcd07ecaf2ff288c1e47c4 |
| parent | cc39ec1008ce584e088e79210b69d3359357463b |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 21 | import { RadioGroup } from "../ui/radio-group"; | 21 | import { RadioGroup } from "../ui/radio-group"; |
| 22 | import { toastManager } from "../ui/toast"; | 22 | import { toastManager } from "../ui/toast"; |
| 23 | import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; | 23 | import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; |
| 24 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; | ||
| 24 | import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; | 25 | import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; |
| 25 | import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard"; | 26 | import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard"; |
| 26 | import { | 27 | import { |
| ... | @@ -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 | <input | 342 | <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"; |
| 2 | 2 | ||
| 3 | import { PipetteIcon, XIcon } from "lucide-react"; | 3 | import { PipetteIcon, XIcon } from "lucide-react"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; | 4 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 5 | 5 | ||
| 6 | import { hexToHsv, hsvToHex, type HsvColor } from "../../lib/color"; | ||
| 7 | import { ColorHueSlider, ColorSaturationValuePlane } from "../ui/color-picker"; | ||
| 8 | import { Input } from "../ui/input"; | ||
| 6 | import { Button } from "../ui/button"; | 9 | import { Button } from "../ui/button"; |
| 7 | import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 10 | import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 8 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 11 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| ... | @@ -10,149 +13,40 @@ import { cn } from "../../lib/utils"; | ... | @@ -10,149 +13,40 @@ import { cn } from "../../lib/utils"; |
| 10 | 13 | ||
| 11 | const FALLBACK_ACCENT_COLOR = "#2563eb"; | 14 | const FALLBACK_ACCENT_COLOR = "#2563eb"; |
| 12 | 15 | ||
| 13 | function clamp(value: number, min = 0, max = 1) { | ||
| 14 | return Math.min(max, Math.max(min, value)); | ||
| 15 | } | ||
| 16 | |||
| 17 | function 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 | |||
| 47 | function 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 | |||
| 73 | function ProviderCustomColorPanel(props: { | 16 | function 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); |
| 82 | 24 | ||
| 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 | ); |
| 90 | 32 | ||
| 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 | <div | 35 | <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 | <div | 42 | <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 @@ |
| 1 | import type { KeyboardEvent, PointerEvent } from "react"; | ||
| 2 | import { memo, useCallback, useEffect, useRef, useState } from "react"; | 1 | import { memo, useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette"; | 2 | import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette"; |
| 4 | import { cn } from "../../lib/utils"; | 3 | import { cn } from "../../lib/utils"; |
| 4 | import { hexToHsv, hsvToHex, type HsvColor } from "../../lib/color"; | ||
| 5 | import { ColorHueSlider, ColorSaturationValuePlane } from "../ui/color-picker"; | ||
| 5 | import { Input } from "../ui/input"; | 6 | import { Input } from "../ui/input"; |
| 6 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 7 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 8 | import { 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 | } |
| 29 | 30 | ||
| 30 | type ThemeColorHsv = { | ||
| 31 | h: number; | ||
| 32 | s: number; | ||
| 33 | v: number; | ||
| 34 | }; | ||
| 35 | |||
| 36 | function 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. Alpha | 32 | * 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 or | 33 | * 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 | } |
| 54 | 45 | ||
| 55 | function 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 | |||
| 85 | function 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 | |||
| 112 | function themeHexToRgb(hex: string) { | 46 | function 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); |
| 158 | 91 | ||
| 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 own | 100 | // 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) === normalizedValue | 103 | hsvToHex(current.h, current.s, current.v) === normalizedValue |
| 171 | ? current | 104 | ? current |
| 172 | : themeHexToHsv(normalizedValue), | 105 | : hexToHsv(normalizedValue), |
| 173 | ); | 106 | ); |
| 174 | }, [normalizedValue]); | 107 | }, [normalizedValue]); |
| 175 | 108 | ||
| ... | @@ -203,9 +136,9 @@ function ThemeColorPickerPanel({ | ... | @@ -203,9 +136,9 @@ function ThemeColorPickerPanel({ |
| 203 | }, []); | 136 | }, []); |
| 204 | 137 | ||
| 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 | ); |
| 215 | 148 | ||
| 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 just | 164 | // 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 | <div | 182 | <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 @@ | ||
| 1 | import { act, type ReactNode, type ReactElement } from "react"; | ||
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 3 | import { 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. | ||
| 7 | vi.mock("../ui/popover", () => ({ | ||
| 8 | Popover: ({ children }: { children: ReactNode }) => children, | ||
| 9 | PopoverPopup: ({ children }: { children: ReactNode }) => children, | ||
| 10 | PopoverTrigger: () => null, | ||
| 11 | PopoverClose: () => null, | ||
| 12 | })); | ||
| 13 | vi.mock("../ui/tooltip", () => ({ | ||
| 14 | Tooltip: ({ children }: { children: ReactNode }) => children, | ||
| 15 | TooltipTrigger: ({ render }: { render: ReactElement }) => render, | ||
| 16 | TooltipPopup: () => null, | ||
| 17 | })); | ||
| 18 | |||
| 19 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; | ||
| 20 | import { ThemeColorField } from "./ThemeColorPicker"; | ||
| 21 | |||
| 22 | let renderer: ReactTestRenderer | undefined; | ||
| 23 | let nextFrameId = 0; | ||
| 24 | const frames = new Map<number, FrameRequestCallback>(); | ||
| 25 | |||
| 26 | beforeEach(() => { | ||
| 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 | |||
| 35 | afterEach(async () => { | ||
| 36 | await act(async () => renderer?.unmount()); | ||
| 37 | renderer = undefined; | ||
| 38 | frames.clear(); | ||
| 39 | vi.useRealTimers(); | ||
| 40 | vi.unstubAllGlobals(); | ||
| 41 | }); | ||
| 42 | |||
| 43 | function 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 | |||
| 51 | function plane(label: string) { | ||
| 52 | return renderer!.root.findByProps({ role: "group", "aria-label": label }); | ||
| 53 | } | ||
| 54 | |||
| 55 | async 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 | |||
| 61 | function 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 | |||
| 75 | async 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 | |||
| 83 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | useId, | ||
| 3 | useRef, | ||
| 4 | useState, | ||
| 5 | type KeyboardEvent, | ||
| 6 | type PointerEvent, | ||
| 7 | type RefObject, | ||
| 8 | } from "react"; | ||
| 9 | |||
| 10 | import type { HsvColor } from "../../lib/color"; | ||
| 11 | import { cn } from "../../lib/utils"; | ||
| 12 | |||
| 13 | function 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. */ | ||
| 18 | function 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 | |||
| 56 | type 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 | |||
| 65 | export 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 | |||
| 175 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { hexToHsv, hsvToHex } from "./color"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | export type HsvColor = { | ||
| 2 | h: number; | ||
| 3 | s: number; | ||
| 4 | v: number; | ||
| 5 | }; | ||
| 6 | |||
| 7 | /** Convert a normalized six-digit sRGB hex color to HSV. */ | ||
| 8 | export 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 | |||
| 37 | export 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 | } | ||