From dad92d85994b5cd91cb8021a9af7ed4140f5dd5f Mon Sep 17 00:00:00 2001 From: psychosomat Date: Fri, 10 Jul 2026 03:11:18 +0300 Subject: [PATCH 1/3] feat: add custom gradient editor to background settings Adds a color-wheel-based gradient editor to the background settings panel, allowing users to build custom linear gradients instead of being limited to presets only. - New GradientEditor component with color wheel, harmony modes, brightness slider, and gradient angle knob - New buildGradientFromEditor utility to convert editor state to CSS - classifyWallpaper now handles composite backgrounds (url overlay + trailing gradient) so custom gradients work with the existing preview - i18n keys for 'presets' and 'custom' labels across all 13 locales - Unit tests for gradientBuilder and the new composite-background parsing --- src/components/ui/gradient-editor.tsx | 503 ++++++++++++++++++ src/components/video-editor/SettingsPanel.tsx | 63 ++- src/i18n/locales/ar/settings.json | 4 +- src/i18n/locales/en/settings.json | 4 +- src/i18n/locales/es/settings.json | 4 +- src/i18n/locales/fr/settings.json | 4 +- src/i18n/locales/it/settings.json | 4 +- src/i18n/locales/ja-JP/settings.json | 4 +- src/i18n/locales/ko-KR/settings.json | 4 +- src/i18n/locales/pt-BR/settings.json | 4 +- src/i18n/locales/ru/settings.json | 4 +- src/i18n/locales/tr/settings.json | 4 +- src/i18n/locales/vi/settings.json | 4 +- src/i18n/locales/zh-CN/settings.json | 4 +- src/i18n/locales/zh-TW/settings.json | 4 +- src/lib/gradientBuilder.test.ts | 68 +++ src/lib/gradientBuilder.ts | 41 ++ src/lib/wallpaper.test.ts | 9 + src/lib/wallpaper.ts | 19 + 19 files changed, 720 insertions(+), 35 deletions(-) create mode 100644 src/components/ui/gradient-editor.tsx create mode 100644 src/lib/gradientBuilder.test.ts create mode 100644 src/lib/gradientBuilder.ts diff --git a/src/components/ui/gradient-editor.tsx b/src/components/ui/gradient-editor.tsx new file mode 100644 index 000000000..1a175eea4 --- /dev/null +++ b/src/components/ui/gradient-editor.tsx @@ -0,0 +1,503 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +/* ---------- icons ---------- */ +const PlusIcon = () => ( + + + +); + +const MinusIcon = () => ( + + + +); + +const RingsIcon = () => ( + + + + + +); + +const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); + +const MAX_COLORS = 3; +const MAIN_ID = "main"; +const MAIN_MAX_RADIUS = 40; +let idCounter = 1; + +/* ---------- color-wheel harmony math ---------- */ +type ColorHarmony = { + type: HarmonyType; + count: number; + angles: number[]; +}; + +type HarmonyType = "complementary" | "analogous" | "splitComplementary" | "triadic" | "square"; + +const colorHarmonies: ColorHarmony[] = [ + { type: "complementary", count: 1, angles: [180] }, + { type: "analogous", count: 1, angles: [35] }, + { type: "splitComplementary", count: 2, angles: [150, 210] }, + { type: "triadic", count: 2, angles: [120, 240] }, + { type: "square", count: 2, angles: [90, 270] }, +]; + +function getEligibleHarmonies(secondaryCount: number): ColorHarmony[] { + return colorHarmonies.filter((h) => h.count === secondaryCount); +} + +function getHarmonyAngles(secondaryCount: number, harmonyType: HarmonyType): number[] { + const eligible = getEligibleHarmonies(secondaryCount); + if (eligible.length === 0) return []; + return (eligible.find((h) => h.type === harmonyType) || eligible[0]).angles; +} + +function computeSecondaryPositions( + mainAngle: number, + mainRadius: number, + secondaries: { id: string }[], + harmonyType: HarmonyType, +): { id: string; x: number; y: number; color: string }[] { + const angles = getHarmonyAngles(secondaries.length, harmonyType); + return secondaries.map((p, i) => { + const angle = mainAngle + (angles[i] ?? 0); + const rad = (angle * Math.PI) / 180; + const x = clamp(50 + mainRadius * Math.cos(rad), 5, 95); + const y = clamp(50 + mainRadius * Math.sin(rad), 5, 95); + return { id: p.id, x, y, color: colorFromPolar(angle, mainRadius) }; + }); +} + +/* ---------- position -> color ---------- */ +function hueToRgb(p: number, q: number, t: number): number { + let tt = t; + if (tt < 0) tt += 1; + if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p + (q - p) * 6 * tt; + if (tt < 1 / 2) return q; + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; + return p; +} + +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + let r: number; + let g: number; + let b: number; + if (s === 0) { + r = g = b = l; + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hueToRgb(p, q, h + 1 / 3); + g = hueToRgb(p, q, h); + b = hueToRgb(p, q, h - 1 / 3); + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +} + +function colorFromPolar(angleDeg: number, radiusPct: number): string { + const hue = ((angleDeg % 360) + 360) % 360; + const saturation = clamp(radiusPct / MAIN_MAX_RADIUS, 0, 1); + const [r, g, b] = hslToRgb(hue / 360, saturation, 0.5); + return `rgb(${r}, ${g}, ${b})`; +} + +/* ---------- wavy-slider path ---------- */ +const LINE_PATH = `M 51.373 27.395 L 367.037 27.395`; +const SINE_PATH = `M 51.373 27.395 C 60.14 -8.503 68.906 -8.503 77.671 27.395 C 86.438 63.293 95.205 63.293 103.971 27.395 C 112.738 -8.503 121.504 -8.503 130.271 27.395 C 139.037 63.293 147.803 63.293 156.57 27.395 C 165.335 -8.503 174.101 -8.503 182.868 27.395 C 191.634 63.293 200.4 63.293 209.167 27.395 C 217.933 -8.503 226.7 -8.503 235.467 27.395 C 244.233 63.293 252.999 63.293 261.765 27.395 C 270.531 -8.503 279.297 -8.503 288.064 27.395 C 296.83 63.293 305.596 63.293 314.363 27.395 C 323.13 -8.503 331.896 -8.503 340.662 27.395 M 314.438 27.395 C 323.204 -8.503 331.97 -8.503 340.737 27.395 C 349.503 63.293 358.27 63.293 367.037 27.395`; + +type PathPoint = + | { type: "M"; x: number; y: number } + | { type: "L"; x: number; y: number } + | { type: "C"; x1: number; y1: number; x2: number; y2: number; x: number; y: number }; + +function parseSinePath(pathStr: string): PathPoint[] { + const points: PathPoint[] = []; + const commands = pathStr.match(/[MCL]\s*[\d\s.\-,]+/g); + if (!commands) return points; + + for (const command of commands) { + const type = command.charAt(0); + const coords = command + .slice(1) + .trim() + .split(/[\s,]+/) + .map(Number); + + if (type === "M") { + points.push({ type: "M", x: coords[0], y: coords[1] }); + } else if (type === "C") { + if (coords.length >= 6 && coords.length % 6 === 0) { + for (let i = 0; i < coords.length; i += 6) { + points.push({ + x1: coords[i], + y1: coords[i + 1], + x2: coords[i + 2], + y2: coords[i + 3], + x: coords[i + 4], + y: coords[i + 5], + type: "C", + }); + } + } + } else if (type === "L") { + points.push({ type: "L", x: coords[0], y: coords[1] }); + } + } + return points; +} + +const SINE_POINTS = parseSinePath(SINE_PATH); + +function getInterpolatedWavePath(progress: number): string { + const referenceY = 27.395; + if (SINE_POINTS.length === 0) return progress < 0.5 ? LINE_PATH : SINE_PATH; + if (progress <= 0.001) return LINE_PATH; + if (progress >= 0.999) return SINE_PATH; + + const t = progress; + let d = ""; + for (const p of SINE_POINTS) { + if (p.type === "M") { + d += `M ${p.x} ${referenceY + (p.y - referenceY) * t} `; + } else if (p.type === "C") { + const y1 = referenceY + (p.y1 - referenceY) * t; + const y2 = referenceY + (p.y2 - referenceY) * t; + const y = referenceY + (p.y - referenceY) * t; + d += `C ${p.x1} ${y1} ${p.x2} ${y2} ${p.x} ${y} `; + } else if (p.type === "L") { + d += `L ${p.x} ${p.y} `; + } + } + return d.trim(); +} + +/* ---------- exported state ---------- */ +export type GradientEditorState = { + points: { id: string; x: number; y: number; color: string }[]; + mainX: number; + mainY: number; + mainColor: string; + brightness: number; + angle: number; + harmonyType: HarmonyType; +}; + +type GradientEditorProps = { + onChange?: (state: GradientEditorState) => void; +}; + +export default function GradientEditor({ onChange }: GradientEditorProps) { + const [mainAngle, setMainAngle] = useState(-35); + const [mainRadius, setMainRadius] = useState(20); + const [orbitPoints, setOrbitPoints] = useState<{ id: string }[]>([{ id: "o1" }, { id: "o2" }]); + const [harmonyType, setHarmonyType] = useState("splitComplementary"); + const [brightness, setBrightness] = useState(55); + const [angle, setAngle] = useState(135); + + const canvasRef = useRef(null); + const trackRef = useRef(null); + const draggingMain = useRef(false); + const movedRef = useRef(false); + const mountedRef = useRef(false); + + const totalColors = 1 + orbitPoints.length; + + /* ---------- derived visuals ---------- */ + const mainX = 50 + mainRadius * Math.cos((mainAngle * Math.PI) / 180); + const mainY = 50 + mainRadius * Math.sin((mainAngle * Math.PI) / 180); + const mainColor = colorFromPolar(mainAngle, mainRadius); + + const allPoints = useMemo(() => { + const secondaries = computeSecondaryPositions( + mainAngle, + mainRadius, + orbitPoints, + harmonyType, + ).map((p) => ({ ...p, size: 20 })); + + return [{ id: MAIN_ID, x: mainX, y: mainY, color: mainColor, size: 64 }, ...secondaries]; + }, [mainX, mainY, mainColor, orbitPoints, mainAngle, mainRadius, harmonyType]); + + /* ---------- emit state ---------- */ + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + const cb = onChangeRef.current; + if (!cb) return; + if (!mountedRef.current) { + mountedRef.current = true; + return; + } + + cb({ + points: allPoints.map((p) => ({ id: p.id, x: p.x, y: p.y, color: p.color })), + mainX, + mainY, + mainColor, + brightness, + harmonyType, + angle, + }); + }, [allPoints, mainX, mainY, mainColor, brightness, harmonyType, angle]); + + /* ---------- main point drag ---------- */ + const updateMainFromPointer = useCallback((clientX: number, clientY: number) => { + const el = canvasRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const dx = clientX - cx; + const dy = clientY - cy; + const angle = (Math.atan2(dy, dx) * 180) / Math.PI; + const radius = clamp((Math.hypot(dx, dy) / (rect.width / 2)) * 50, 0, MAIN_MAX_RADIUS); + + setMainAngle(angle); + setMainRadius(radius); + }, []); + + const onMainPointerDown = (e: React.PointerEvent) => { + e.stopPropagation(); + draggingMain.current = true; + movedRef.current = false; + window.addEventListener("pointermove", onMainPointerMove); + window.addEventListener("pointerup", onMainPointerUp); + }; + + const onMainPointerMove = useCallback( + (e: PointerEvent) => { + if (!draggingMain.current) return; + movedRef.current = true; + updateMainFromPointer(e.clientX, e.clientY); + }, + [updateMainFromPointer], + ); + + const onMainPointerUp = useCallback(() => { + draggingMain.current = false; + window.removeEventListener("pointermove", onMainPointerMove); + window.removeEventListener("pointerup", onMainPointerUp); + }, [onMainPointerMove]); + + /* ---------- add / remove / harmony ---------- */ + const addPoint = () => { + if (totalColors >= MAX_COLORS) return; + const id = `o${idCounter++}`; + setOrbitPoints((prev) => [...prev, { id }]); + }; + + const removePoint = () => { + if (totalColors <= 1) return; + setOrbitPoints((prev) => prev.slice(0, -1)); + }; + + const eligibleHarmonies = getEligibleHarmonies(orbitPoints.length); + + const cycleHarmony = () => { + if (eligibleHarmonies.length <= 1) return; + const idx = eligibleHarmonies.findIndex((h) => h.type === harmonyType); + const next = eligibleHarmonies[(idx + 1) % eligibleHarmonies.length]; + setHarmonyType(next.type); + }; + + /* ---------- brightness slider ---------- */ + const setBrightnessFromClientX = (clientX: number) => { + const el = trackRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const pct = clamp(((clientX - rect.left) / rect.width) * 100, 0, 100); + setBrightness(pct); + }; + + /* visual clamp so thumb stays on the visible wave path */ + const WAVE_START_PCT = (51.373 / 420) * 100; + const WAVE_END_PCT = (367.037 / 420) * 100; + const clampedBrightness = clamp(brightness, WAVE_START_PCT, WAVE_END_PCT); + + const onSliderPointerDown = (e: React.PointerEvent) => { + setBrightnessFromClientX(e.clientX); + const move = (ev: PointerEvent) => setBrightnessFromClientX(ev.clientX); + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }; + + /* ---------- angle knob ---------- */ + // Angle knob + const angleKnobRef = useRef(null); + const angleKnobAngle = ((angle % 360) / 360) * 360; + const setAngleFromClientPos = (clientX: number, clientY: number) => { + const el = angleKnobRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + let a = (Math.atan2(-(clientY - cy), clientX - cx) * 180) / Math.PI; + if (a < 0) a += 360; + setAngle(Math.round(a)); + }; + const onAngleKnobPointerDown = (e: React.PointerEvent) => { + e.stopPropagation(); + setAngleFromClientPos(e.clientX, e.clientY); + const move = (ev: PointerEvent) => setAngleFromClientPos(ev.clientX, ev.clientY); + const up = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }; + + // Brightness filter (dark theme only) + const brightnessFilter = `brightness(${0.62 * (0.55 + (brightness / 100) * 0.9)}) saturate(0.95)`; + + const wavePath = useMemo(() => getInterpolatedWavePath(brightness / 100), [brightness]); + + return ( +
+ {/* Color Wheel Canvas */} +
+ {/* Center marker */} +
+ + {/* Points container with brightness filter */} +
+ {/* Secondary points */} + {allPoints + .filter((p) => p.id !== MAIN_ID) + .map((p) => ( +
+ ))} + + {/* Main draggable point */} +
+
+ + {/* Controls on canvas */} +
+ + + + + +
+
+ + {/* Bottom controls: Brightness + Angle */} +
+ {/* Brightness wavy slider */} +
+ + + + +
+
+ + {/* Angle knob */} +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 5e0cc8062..79a8c1e64 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -51,11 +51,13 @@ import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, } from "@/lib/exporter"; +import { buildGradientFromEditor } from "@/lib/gradientBuilder"; import { cn } from "@/lib/utils"; import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { getTestId } from "@/utils/getTestId"; import ColorPicker from "../ui/color-picker"; +import GradientEditor from "../ui/gradient-editor"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload"; @@ -1814,28 +1816,45 @@ export function SettingsPanel({ /> - -
- {GRADIENTS.map((g, idx) => ( -
{ - setGradient(g); - onWallpaperChange(g); - }} - role="button" - /> - ))} + +
+
+ {t("background.presets")} +
+
+ {GRADIENTS.map((g, idx) => ( +
{ + setGradient(g); + onWallpaperChange(g); + }} + role="button" + /> + ))} +
+
+
+
+ {t("background.custom")} +
+ { + const css = buildGradientFromEditor(state); + setGradient(css); + onWallpaperChange(css); + }} + />
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 2e34bae8b..ee5a6e5cf 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -65,10 +65,12 @@ "image": "صورة", "color": "لون", "gradient": "تدرج لوني", + "custom": "مخصص", "uploadCustom": "رفع صورة مخصصة", "gradientLabel": "تدرج لوني {{index}}", "colorWheel": "عجلة الألوان", - "colorPalette": "لوحة الألوان" + "colorPalette": "لوحة الألوان", + "presets": "إعدادات مسبقة" }, "crop": { "title": "اقتصاص", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 93cef6d4c..75e6d4b17 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -66,10 +66,12 @@ "image": "Image", "color": "Color", "gradient": "Gradient", + "custom": "Custom", "uploadCustom": "Upload Custom", "gradientLabel": "Gradient {{index}}", "colorWheel": "Color Wheel", - "colorPalette": "Color Palette" + "colorPalette": "Color Palette", + "presets": "Presets" }, "crop": { "title": "Crop", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index ac9267b41..79dc6bd88 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -65,10 +65,12 @@ "image": "Imagen", "color": "Color", "gradient": "Degradado", + "custom": "Personalizado", "uploadCustom": "Subir personalizado", "gradientLabel": "Degradado {{index}}", "colorWheel": "Rueda de colores", - "colorPalette": "Paleta de colores" + "colorPalette": "Paleta de colores", + "presets": "Ajustes preestablecidos" }, "crop": { "title": "Recortar", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 0b67cd266..ed1602026 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -65,10 +65,12 @@ "image": "Image", "color": "Couleur", "gradient": "Dégradé", + "custom": "Personnalisé", "uploadCustom": "Téléverser une image", "gradientLabel": "Dégradé {{index}}", "colorWheel": "Roue chromatique", - "colorPalette": "Palette de couleurs" + "colorPalette": "Palette de couleurs", + "presets": "Préréglages" }, "crop": { "title": "Recadrage", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 9ee07a16a..58feece9f 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -65,10 +65,12 @@ "image": "Immagine", "color": "Colore", "gradient": "Sfumatura", + "custom": "Personalizzato", "uploadCustom": "Carica personalizzato", "gradientLabel": "Sfumatura {{index}}", "colorWheel": "Ruota dei colori", - "colorPalette": "Tavolozza dei colori" + "colorPalette": "Tavolozza dei colori", + "presets": "Predefiniti" }, "crop": { "title": "Ritaglia", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 69af9b2e7..d87bd4c17 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -65,10 +65,12 @@ "image": "画像", "color": "色", "gradient": "グラデーション", + "custom": "カスタム", "uploadCustom": "カスタム画像を読み込む", "gradientLabel": "グラデーション {{index}}", "colorWheel": "カラーホイール", - "colorPalette": "カラーパレット" + "colorPalette": "カラーパレット", + "presets": "プリセット" }, "crop": { "title": "クロップ", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 808fd71a9..c16e582a2 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -65,10 +65,12 @@ "image": "이미지", "color": "색상", "gradient": "그라디언트", + "custom": "사용자 지정", "uploadCustom": "직접 업로드", "gradientLabel": "그라디언트 {{index}}", "colorWheel": "색상 휠", - "colorPalette": "색상 팔레트" + "colorPalette": "색상 팔레트", + "presets": "프리셋" }, "crop": { "title": "자르기", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index eddd1c43b..b5258801e 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -63,10 +63,12 @@ "image": "Imagem", "color": "Cor", "gradient": "Gradiente", + "custom": "Personalizado", "uploadCustom": "Enviar Personalizada", "gradientLabel": "Gradiente {{index}}", "colorWheel": "Roda de Cores", - "colorPalette": "Paleta de Cores" + "colorPalette": "Paleta de Cores", + "presets": "Predefinições" }, "crop": { "title": "Cortar", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 3b83a98dd..b5dc3a73e 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -65,10 +65,12 @@ "image": "Изображение", "color": "Цвет", "gradient": "Градиент", + "custom": "Свой", "uploadCustom": "Загрузить свой", "gradientLabel": "Градиент {{index}}", "colorWheel": "Цветовой круг", - "colorPalette": "Палитра цветов" + "colorPalette": "Палитра цветов", + "presets": "Пресеты" }, "crop": { "title": "Обрезка", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index b19fd8075..2d90a1001 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -65,10 +65,12 @@ "image": "Görüntü", "color": "Renk", "gradient": "Gradyan", + "custom": "Özel", "uploadCustom": "Özel Yükle", "gradientLabel": "Gradyan {{index}}", "colorWheel": "Renk çarkı", - "colorPalette": "Renk paleti" + "colorPalette": "Renk paleti", + "presets": "Ön ayarlar" }, "crop": { "title": "Kırpma", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index fe1585e75..93d091a7a 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -65,10 +65,12 @@ "image": "Hình ảnh", "color": "Màu sắc", "gradient": "Dải màu", + "custom": "Tùy chỉnh", "uploadCustom": "Tải lên tùy chỉnh", "gradientLabel": "Dải màu {{index}}", "colorPalette": "Bảng màu", - "colorWheel": "Vòng màu" + "colorWheel": "Vòng màu", + "presets": "Có sẵn" }, "crop": { "title": "Cắt xén", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index d1e7c0136..1b96ee1b9 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -65,10 +65,12 @@ "image": "图片", "color": "颜色", "gradient": "渐变", + "custom": "自定义", "uploadCustom": "上传自定义", "gradientLabel": "渐变 {{index}}", "colorWheel": "颜色轮", - "colorPalette": "颜色调色板" + "colorPalette": "颜色调色板", + "presets": "预设" }, "crop": { "title": "裁剪", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 5ec315819..dda105723 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -66,10 +66,12 @@ "image": "圖片", "color": "顏色", "gradient": "漸層", + "custom": "自訂", "uploadCustom": "上傳自訂", "gradientLabel": "漸層 {{index}}", "colorWheel": "色輪", - "colorPalette": "調色盤" + "colorPalette": "調色盤", + "presets": "預設" }, "crop": { "title": "裁剪", diff --git a/src/lib/gradientBuilder.test.ts b/src/lib/gradientBuilder.test.ts new file mode 100644 index 000000000..c711d4ec9 --- /dev/null +++ b/src/lib/gradientBuilder.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { GradientEditorState } from "@/components/ui/gradient-editor"; +import { buildGradientFromEditor } from "./gradientBuilder"; + +const baseState = (overrides: Partial = {}): GradientEditorState => ({ + points: [{ id: "main", x: 30, y: 40, color: "rgb(255, 0, 0)" }], + mainX: 30, + mainY: 40, + mainColor: "rgb(255, 0, 0)", + brightness: 100, + harmonyType: "splitComplementary", + ...overrides, +}); + +const threePoints = (brightness: number): GradientEditorState => + baseState({ + brightness, + points: [ + { id: "main", x: 30, y: 40, color: "rgb(255, 0, 0)" }, + { id: "o1", x: 70, y: 20, color: "rgb(0, 255, 0)" }, + { id: "o2", x: 50, y: 80, color: "rgb(0, 0, 255)" }, + ], + }); + +describe("buildGradientFromEditor", () => { + it("emits a 3-stop linear gradient at 135deg", () => { + const css = buildGradientFromEditor(threePoints(100)); + expect(css).toBe( + "linear-gradient(135deg, rgb(255, 0, 0) 0%, rgb(0, 255, 0) 50%, rgb(0, 0, 255) 100%)", + ); + }); + + it("scales every stop's color with the brightness slider", () => { + const full = buildGradientFromEditor(threePoints(100)); + const half = buildGradientFromEditor(threePoints(50)); + expect(half).toBe( + "linear-gradient(135deg, rgb(128, 0, 0) 0%, rgb(0, 128, 0) 50%, rgb(0, 0, 128) 100%)", + ); + expect(full).not.toBe(half); + }); + + it("handles one and two point states", () => { + const one = buildGradientFromEditor(baseState({ brightness: 100 })); + expect(one).toBe("linear-gradient(135deg, rgb(255, 0, 0) 0%, rgb(255, 0, 0) 100%)"); + + const two = buildGradientFromEditor( + baseState({ + brightness: 100, + points: [ + { id: "main", x: 30, y: 40, color: "rgb(10, 20, 30)" }, + { id: "o1", x: 70, y: 20, color: "rgb(40, 50, 60)" }, + ], + }), + ); + expect(two).toBe( + "linear-gradient(135deg, rgb(10, 20, 30) 0%, rgb(40, 50, 60) 50%, rgb(40, 50, 60) 100%)", + ); + }); + + it("falls back to a brightness-driven base color when there are no points", () => { + expect(buildGradientFromEditor(baseState({ points: [], brightness: 0 }))).toBe( + "hsl(0, 0%, 0%)", + ); + expect(buildGradientFromEditor(baseState({ points: [], brightness: 100 }))).toBe( + "hsl(0, 0%, 18%)", + ); + }); +}); diff --git a/src/lib/gradientBuilder.ts b/src/lib/gradientBuilder.ts new file mode 100644 index 000000000..69b44f416 --- /dev/null +++ b/src/lib/gradientBuilder.ts @@ -0,0 +1,41 @@ +import type { GradientEditorState } from "@/components/ui/gradient-editor"; + +/* ---------- editor state -> CSS background ---------- + The editor produces a clean 3-stop LINEAR gradient - the same shape + as a "real" 3-color gradient picker, not clustered blobs. Brightness + is a per-channel multiplier (0 = black, 100 = full color). */ +export function buildGradientFromEditor(state: GradientEditorState): string { + const { points, brightness, angle = 135 } = state; + const colors = points.slice(0, 3).map((p) => applyBrightness(p.color, brightness)); + + if (colors.length === 0) { + return baseForBrightness(brightness); + } + if (colors.length === 1) { + return `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[0]} 100%)`; + } + if (colors.length === 2) { + return `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[1]} 50%, ${colors[1]} 100%)`; + } + + return `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[1]} 50%, ${colors[2]} 100%)`; +} + +function applyBrightness(color: string, brightness: number): string { + const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (!match) return color; + const k = brightness / 100; + const r = Math.round(Number(match[1]) * k); + const g = Math.round(Number(match[2]) * k); + const b = Math.round(Number(match[3]) * k); + return `rgb(${r}, ${g}, ${b})`; +} + +function baseForBrightness(brightness: number): string { + const l = Math.round((brightness / 100) * 18); + return `hsl(0, 0%, ${clamp(l, 0, 22)}%)`; +} + +function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} diff --git a/src/lib/wallpaper.test.ts b/src/lib/wallpaper.test.ts index 02596aaec..3e03a1664 100644 --- a/src/lib/wallpaper.test.ts +++ b/src/lib/wallpaper.test.ts @@ -121,6 +121,15 @@ describe("classifyWallpaper", () => { path: DEFAULT_WALLPAPER, }); }); + + it("strips a leading url() overlay layer and returns the trailing gradient", () => { + const composite = + 'url("data:image/svg+xml;utf8,") repeat, linear-gradient(135deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)'; + expect(classifyWallpaper(composite)).toEqual({ + kind: "gradient", + value: "linear-gradient(135deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)", + }); + }); }); describe("resolveImageWallpaperUrl", () => { diff --git a/src/lib/wallpaper.ts b/src/lib/wallpaper.ts index 6974a04c9..df739c27f 100644 --- a/src/lib/wallpaper.ts +++ b/src/lib/wallpaper.ts @@ -23,6 +23,14 @@ export function classifyWallpaper(value: string): WallpaperClassification { if (trimmed === "") { return { kind: "color", value: "#000000" }; } + // Multi-background (e.g. "url(noise), linear-gradient(...)") - peel + // the gradient layer off so the existing parser can still handle it. + // Noise/url overlays are lost on export, but the live preview paints + // the full string via CSS so the overlay shows up there. + if (trimmed.startsWith("url(")) { + const gradient = extractTrailingGradient(trimmed); + if (gradient) return { kind: "gradient", value: gradient }; + } if (trimmed.startsWith("#") || COLOR_FUNC_RE.test(trimmed)) { return { kind: "color", value: trimmed }; } @@ -35,6 +43,17 @@ export function classifyWallpaper(value: string): WallpaperClassification { return { kind: "color", value: trimmed }; } +function extractTrailingGradient(value: string): string | null { + const matches: number[] = []; + for (const type of ["linear", "radial", "conic"]) { + const idx = value.lastIndexOf(`, ${type}-gradient(`); + if (idx >= 0) matches.push(idx); + } + if (matches.length === 0) return null; + const start = Math.max(...matches) + 2; + return value.slice(start); +} + const ALLOWED_IMAGE_PREFIX = "/wallpapers/"; export class UnsafeImagePrefixError extends Error { From 7fd5ce4af588db1a74c59350b7096eff3488c5ed Mon Sep 17 00:00:00 2001 From: psychosomat Date: Fri, 10 Jul 2026 03:39:40 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20a11y,?= =?UTF-8?q?=20listener=20cleanup,=20repeating=20gradients,=20import=20orde?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Type-only import for PointerEvent (Biome import discipline) - Drag handlers clean up on pointerpointercancel and unmount via shared ref - Main point, brightness slider, and angle knob are keyboard-focusable with role=slider, aria-* attrs, and arrow-key handlers - baseState() test fixture includes the required angle field - extractTrailingGradient handles repeating-* gradient variants - Added test for composite backgrounds with repeating-linear-gradient --- src/components/ui/gradient-editor.tsx | 118 +++++++++++++++++++++----- src/lib/gradientBuilder.test.ts | 1 + src/lib/wallpaper.test.ts | 9 ++ src/lib/wallpaper.ts | 9 +- 4 files changed, 116 insertions(+), 21 deletions(-) diff --git a/src/components/ui/gradient-editor.tsx b/src/components/ui/gradient-editor.tsx index 1a175eea4..bf28652ab 100644 --- a/src/components/ui/gradient-editor.tsx +++ b/src/components/ui/gradient-editor.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; /* ---------- icons ---------- */ const PlusIcon = () => ( @@ -203,6 +204,14 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { const draggingMain = useRef(false); const movedRef = useRef(false); const mountedRef = useRef(false); + const dragCleanup = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => { + dragCleanup.current?.(); + dragCleanup.current = null; + }; + }, []); const totalColors = 1 + orbitPoints.length; @@ -263,12 +272,22 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { setMainRadius(radius); }, []); - const onMainPointerDown = (e: React.PointerEvent) => { + const onMainPointerDown = (e: ReactPointerEvent) => { e.stopPropagation(); draggingMain.current = true; movedRef.current = false; + dragCleanup.current?.(); + const onUp = () => { + draggingMain.current = false; + window.removeEventListener("pointermove", onMainPointerMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + dragCleanup.current = null; + }; + dragCleanup.current = onUp; window.addEventListener("pointermove", onMainPointerMove); - window.addEventListener("pointerup", onMainPointerUp); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); }; const onMainPointerMove = useCallback( @@ -280,12 +299,6 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { [updateMainFromPointer], ); - const onMainPointerUp = useCallback(() => { - draggingMain.current = false; - window.removeEventListener("pointermove", onMainPointerMove); - window.removeEventListener("pointerup", onMainPointerUp); - }, [onMainPointerMove]); - /* ---------- add / remove / harmony ---------- */ const addPoint = () => { if (totalColors >= MAX_COLORS) return; @@ -321,15 +334,20 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { const WAVE_END_PCT = (367.037 / 420) * 100; const clampedBrightness = clamp(brightness, WAVE_START_PCT, WAVE_END_PCT); - const onSliderPointerDown = (e: React.PointerEvent) => { + const onSliderPointerDown = (e: ReactPointerEvent) => { setBrightnessFromClientX(e.clientX); + dragCleanup.current?.(); const move = (ev: PointerEvent) => setBrightnessFromClientX(ev.clientX); - const up = () => { + const onUp = () => { window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + dragCleanup.current = null; }; + dragCleanup.current = onUp; window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); }; /* ---------- angle knob ---------- */ @@ -346,16 +364,21 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { if (a < 0) a += 360; setAngle(Math.round(a)); }; - const onAngleKnobPointerDown = (e: React.PointerEvent) => { + const onAngleKnobPointerDown = (e: ReactPointerEvent) => { e.stopPropagation(); setAngleFromClientPos(e.clientX, e.clientY); + dragCleanup.current?.(); const move = (ev: PointerEvent) => setAngleFromClientPos(ev.clientX, ev.clientY); - const up = () => { + const onUp = () => { window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", up); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + dragCleanup.current = null; }; + dragCleanup.current = onUp; window.addEventListener("pointermove", move); - window.addEventListener("pointerup", up); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); }; // Brightness filter (dark theme only) @@ -363,6 +386,40 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { const wavePath = useMemo(() => getInterpolatedWavePath(brightness / 100), [brightness]); + /* ---------- keyboard handlers ---------- */ + const onMainKeyDown = useCallback((e: React.KeyboardEvent) => { + const step = e.shiftKey ? 10 : 5; + if (e.key === "ArrowLeft" || e.key === "ArrowDown") { + e.preventDefault(); + setMainAngle((a) => a - step); + } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { + e.preventDefault(); + setMainAngle((a) => a + step); + } + }, []); + + const onSliderKeyDown = useCallback((e: React.KeyboardEvent) => { + const step = e.shiftKey ? 10 : 2; + if (e.key === "ArrowLeft" || e.key === "ArrowDown") { + e.preventDefault(); + setBrightness((b) => clamp(b - step, 0, 100)); + } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { + e.preventDefault(); + setBrightness((b) => clamp(b + step, 0, 100)); + } + }, []); + + const onAngleKeyDown = useCallback((e: React.KeyboardEvent) => { + const step = e.shiftKey ? 15 : 5; + if (e.key === "ArrowLeft" || e.key === "ArrowDown") { + e.preventDefault(); + setAngle((a) => (a - step + 360) % 360); + } else if (e.key === "ArrowRight" || e.key === "ArrowUp") { + e.preventDefault(); + setAngle((a) => (a + step) % 360); + } + }, []); + return (
{/* Color Wheel Canvas */} @@ -398,7 +455,14 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { {/* Main draggable point */}
diff --git a/src/lib/gradientBuilder.test.ts b/src/lib/gradientBuilder.test.ts index c711d4ec9..d8f530cbb 100644 --- a/src/lib/gradientBuilder.test.ts +++ b/src/lib/gradientBuilder.test.ts @@ -8,6 +8,7 @@ const baseState = (overrides: Partial = {}): GradientEditor mainY: 40, mainColor: "rgb(255, 0, 0)", brightness: 100, + angle: 135, harmonyType: "splitComplementary", ...overrides, }); diff --git a/src/lib/wallpaper.test.ts b/src/lib/wallpaper.test.ts index 3e03a1664..3e8bdab42 100644 --- a/src/lib/wallpaper.test.ts +++ b/src/lib/wallpaper.test.ts @@ -130,6 +130,15 @@ describe("classifyWallpaper", () => { value: "linear-gradient(135deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)", }); }); + + it("handles repeating-gradient variants in composite backgrounds", () => { + const composite = + 'url("data:image/svg+xml;utf8,") repeat, repeating-linear-gradient(45deg, red, blue)'; + expect(classifyWallpaper(composite)).toEqual({ + kind: "gradient", + value: "repeating-linear-gradient(45deg, red, blue)", + }); + }); }); describe("resolveImageWallpaperUrl", () => { diff --git a/src/lib/wallpaper.ts b/src/lib/wallpaper.ts index df739c27f..9eefa5265 100644 --- a/src/lib/wallpaper.ts +++ b/src/lib/wallpaper.ts @@ -45,7 +45,14 @@ export function classifyWallpaper(value: string): WallpaperClassification { function extractTrailingGradient(value: string): string | null { const matches: number[] = []; - for (const type of ["linear", "radial", "conic"]) { + for (const type of [ + "linear", + "radial", + "conic", + "repeating-linear", + "repeating-radial", + "repeating-conic", + ]) { const idx = value.lastIndexOf(`, ${type}-gradient(`); if (idx >= 0) matches.push(idx); } From 54adfc1dab41a39e5afbb3df1527a0bd9f97437e Mon Sep 17 00:00:00 2001 From: psychosomat Date: Sun, 12 Jul 2026 11:24:28 +0300 Subject: [PATCH 3/3] fix: address maintainer review feedback on gradient editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace module-level idCounter with per-instance useRef seeded from the initial orbit points, fixing duplicate React keys after remove→add - Remove unused movedRef dead code - Align preview brightness filter with applyBrightness multiplier (WYSIWYG) - Add type=button to add/remove/harmony controls - Make extractTrailingGradient whitespace-tolerant with regex - Add gradient-editor.test.tsx covering ID generation, add/remove/harmony state machine, and disabled button states --- src/components/ui/gradient-editor.test.tsx | 69 ++++++++++++++++++++++ src/components/ui/gradient-editor.tsx | 20 ++++--- src/lib/wallpaper.test.ts | 16 +++++ src/lib/wallpaper.ts | 17 +----- 4 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 src/components/ui/gradient-editor.test.tsx diff --git a/src/components/ui/gradient-editor.test.tsx b/src/components/ui/gradient-editor.test.tsx new file mode 100644 index 000000000..0d54a26ea --- /dev/null +++ b/src/components/ui/gradient-editor.test.tsx @@ -0,0 +1,69 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { GradientEditorState } from "./gradient-editor"; +import GradientEditor from "./gradient-editor"; + +const getLastState = (onChange: ReturnType): GradientEditorState => { + const calls = onChange.mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1][0] as GradientEditorState; +}; + +describe("GradientEditor", () => { + it("emits unique point IDs after an interaction", async () => { + const onChange = vi.fn(); + render(); + + const removeButton = screen.getByRole("button", { name: "Remove color" }); + fireEvent.click(removeButton); + await waitFor(() => expect(onChange).toHaveBeenCalled()); + + const state = getLastState(onChange); + const ids = state.points.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toContain("main"); + expect(ids).toHaveLength(2); + }); + + it("generates a fresh ID after remove→add instead of reusing an existing one", async () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Remove color" })); + await waitFor(() => expect(getLastState(onChange).points).toHaveLength(2)); + expect(getLastState(onChange).points.map((p) => p.id)).toEqual(["main", "o1"]); + + fireEvent.click(screen.getByRole("button", { name: "Add color" })); + await waitFor(() => expect(getLastState(onChange).points).toHaveLength(3)); + const finalIds = getLastState(onChange).points.map((p) => p.id); + expect(new Set(finalIds).size).toBe(finalIds.length); + expect(finalIds).toContain("o3"); + }); + + it("disables add at MAX_COLORS and remove at one color", async () => { + render(); + + const addButton = screen.getByRole("button", { name: "Add color" }); + const removeButton = screen.getByRole("button", { name: "Remove color" }); + + expect(addButton).toBeDisabled(); + expect(removeButton).not.toBeDisabled(); + + fireEvent.click(removeButton); + await waitFor(() => expect(addButton).not.toBeDisabled()); + + fireEvent.click(removeButton); + await waitFor(() => expect(removeButton).toBeDisabled()); + expect(addButton).not.toBeDisabled(); + }); + + it("cycles harmony when eligible", async () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Cycle harmony" })); + await waitFor(() => expect(onChange).toHaveBeenCalled()); + expect(getLastState(onChange).harmonyType).not.toBe("splitComplementary"); + }); +}); diff --git a/src/components/ui/gradient-editor.tsx b/src/components/ui/gradient-editor.tsx index bf28652ab..19bb98603 100644 --- a/src/components/ui/gradient-editor.tsx +++ b/src/components/ui/gradient-editor.tsx @@ -27,7 +27,6 @@ const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(mi const MAX_COLORS = 3; const MAIN_ID = "main"; const MAIN_MAX_RADIUS = 40; -let idCounter = 1; /* ---------- color-wheel harmony math ---------- */ type ColorHarmony = { @@ -194,7 +193,11 @@ type GradientEditorProps = { export default function GradientEditor({ onChange }: GradientEditorProps) { const [mainAngle, setMainAngle] = useState(-35); const [mainRadius, setMainRadius] = useState(20); - const [orbitPoints, setOrbitPoints] = useState<{ id: string }[]>([{ id: "o1" }, { id: "o2" }]); + const idCounter = useRef(1); + const [orbitPoints, setOrbitPoints] = useState<{ id: string }[]>(() => [ + { id: `o${idCounter.current++}` }, + { id: `o${idCounter.current++}` }, + ]); const [harmonyType, setHarmonyType] = useState("splitComplementary"); const [brightness, setBrightness] = useState(55); const [angle, setAngle] = useState(135); @@ -202,7 +205,6 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { const canvasRef = useRef(null); const trackRef = useRef(null); const draggingMain = useRef(false); - const movedRef = useRef(false); const mountedRef = useRef(false); const dragCleanup = useRef<(() => void) | null>(null); @@ -275,7 +277,6 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { const onMainPointerDown = (e: ReactPointerEvent) => { e.stopPropagation(); draggingMain.current = true; - movedRef.current = false; dragCleanup.current?.(); const onUp = () => { draggingMain.current = false; @@ -293,7 +294,6 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { const onMainPointerMove = useCallback( (e: PointerEvent) => { if (!draggingMain.current) return; - movedRef.current = true; updateMainFromPointer(e.clientX, e.clientY); }, [updateMainFromPointer], @@ -302,7 +302,7 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { /* ---------- add / remove / harmony ---------- */ const addPoint = () => { if (totalColors >= MAX_COLORS) return; - const id = `o${idCounter++}`; + const id = `o${idCounter.current++}`; setOrbitPoints((prev) => [...prev, { id }]); }; @@ -381,8 +381,9 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { window.addEventListener("pointercancel", onUp); }; - // Brightness filter (dark theme only) - const brightnessFilter = `brightness(${0.62 * (0.55 + (brightness / 100) * 0.9)}) saturate(0.95)`; + // Preview brightness must match the per-channel multiplier in + // applyBrightness so the wheel is WYSIWYG. + const brightnessFilter = `brightness(${brightness / 100})`; const wavePath = useMemo(() => getInterpolatedWavePath(brightness / 100), [brightness]); @@ -475,6 +476,7 @@ export default function GradientEditor({ onChange }: GradientEditorProps) { {/* Controls on canvas */}