From 11fd2876b2c5d6412ebf3daa9b280300774af655 Mon Sep 17 00:00:00 2001 From: kinvsh Date: Wed, 21 Jan 2026 00:58:24 +0500 Subject: [PATCH] bento-grids v0.1 --- .ai/BENTO_REFACTOR.md | 297 --------------- .gitignore | 3 + src/components/ThemeBentoPicker.tsx | 555 ++++++++++++++++++++++++++++ src/data/colorPresets.ts | 179 +++++++++ src/pages/AdminSettings.tsx | 122 +----- src/utils/colorConversion.ts | 102 +++++ 6 files changed, 853 insertions(+), 405 deletions(-) delete mode 100644 .ai/BENTO_REFACTOR.md create mode 100644 src/components/ThemeBentoPicker.tsx create mode 100644 src/data/colorPresets.ts create mode 100644 src/utils/colorConversion.ts diff --git a/.ai/BENTO_REFACTOR.md b/.ai/BENTO_REFACTOR.md deleted file mode 100644 index 3fab39c..0000000 --- a/.ai/BENTO_REFACTOR.md +++ /dev/null @@ -1,297 +0,0 @@ -# Bento Grids Refactor - -> **Ветка:** `bento-grids` -> **Статус:** В работе -> **Последнее обновление:** 2026-01-20 - -## Референс - -- **Стиль:** Bento Grids (карточки разных размеров в сетке) -- **Цветовая схема:** Зеленый неон на темном фоне, чистота -- **Шрифт:** Manrope (современный гротеск, заменён с Urbanist) -- **Скругления:** Крупные (24-32px) - ---- - -## План рефакторинга - -### Этап 1: Фундамент (Vibe Check) -> Цель: Пустая страница уже выглядит "секси" - -| # | Задача | Статус | Заметки | -|---|--------|--------|---------| -| 1.1 | Подключить Urbanist шрифт | `[x]` | Google Fonts, заменить Inter | -| 1.2 | Tailwind: bento radius/spacing | `[x]` | `rounded-bento`, `rounded-4xl`, `spacing.bento` | -| 1.3 | globals.css: CSS переменные Bento | `[x]` | `--bento-radius`, `--bento-gap`, `--bento-padding` | - -**Файлы:** -- `src/styles/globals.css` -- `tailwind.config.js` - ---- - -### Этап 2: Атом (BentoCard) -> Цель: Один идеальный компонент-карточка - -| # | Задача | Статус | Заметки | -|---|--------|--------|---------| -| 2.1 | Создать BentoCard компонент | `[x]` | С вариантами размеров | -| 2.2 | Обновить .card в globals.css | `[x]` | Новые классы .bento-card, .bento-card-hover, .bento-card-glow | - -**Файлы:** -- `src/components/ui/BentoCard.tsx` (новый) -- `src/styles/globals.css` - -**API компонента:** -```tsx -type BentoSize = 'sm' | 'md' | 'lg' | 'xl' // 1x1, 2x1, 1x2, 2x2 - -interface BentoCardProps { - size?: BentoSize - children: React.ReactNode - className?: string - hover?: boolean - as?: 'div' | 'Link' | 'button' -} -``` - ---- - -### Этап 3: Скелет (Floating TabBar) -> Цель: Bottom navigation как "парящий остров" - -| # | Задача | Статус | Заметки | -|---|--------|--------|---------| -| 3.1 | Floating island стиль | `[x]` | margin 16px от краев, shadow | -| 3.2 | Active state с pill | `[x]` | rounded-2xl фон под активной иконкой | -| 3.3 | Убрать border-top | `[x]` | Заменено на shadow | - -**Файлы:** -- `src/components/layout/Layout.tsx` (секция bottom-nav) -- `src/styles/globals.css` (`.bottom-nav` классы) - ---- - -### Этап 4: Мясо (Dashboard Grid) -> Цель: Главная страница в стиле Bento - -| # | Задача | Статус | Заметки | -|---|--------|--------|---------| -| 4.1 | Создать .bento-grid контейнер | `[x]` | CSS Grid с gap, 2 колонки mobile, 4 desktop | -| 4.2 | Subscription Status → bento-card | `[x]` | Главная карточка подписки | -| 4.3 | Stats Grid → bento-grid + bento-card-hover | `[x]` | Balance, Subscription, Referrals, Earnings | -| 4.4 | Quick Actions → bento-card | `[x]` | Контейнер для кнопок | -| 4.5 | Trial + Wheel Banner → bento-card-glow/hover | `[x]` | Акцентные карточки | - -**Файлы:** -- `src/pages/Dashboard.tsx` -- `src/styles/globals.css` - -**Правило:** Логику JS/TS НЕ трогаем — только UI обёртки. - ---- - -## Решения и обоснования - -### Почему Urbanist, а не Inter? -- Urbanist более геометричный, лучше подходит под Bento-эстетику -- Хорошая читаемость на мобильных -- Бесплатный, Google Fonts - -### Почему rounded-3xl (24px)? -- Стандарт Bento — крупные скругления -- 16px (текущий rounded-2xl) выглядит слишком "приложенечно" -- 32px (rounded-4xl) для особо крупных элементов - -### Floating TabBar vs прилипший -- Floating выглядит премиально -- Отделяет навигацию от контента визуально -- Работает с safe-area на iOS - ---- - -## Что НЕ делаем - -- ✅ ~~Модалки (ConnectionModal, TopUpModal)~~ — Phase 6 complete -- ✅ ~~Страницы кроме Dashboard~~ — Phase 5 complete -- ✅ ~~Header~~ — Phase 7 complete -- ❌ Рефакторинг логики — только UI - ---- - -## Прогресс - -``` -Этап 1: ██████████ 100% -Этап 2: ██████████ 100% -Этап 3: ██████████ 100% -Этап 4: ██████████ 100% -Этап 5: ██████████ 100% -Этап 6: ██████████ 100% -Этап 7: ██████████ 100% -Этап 8: ██████████ 100% -Этап 9: ██████████ 100% -───────────────────── -Общий: ██████████ 100% -``` - ---- - -## Заметки для агентов - -**Контекст:** Это Mini App для Telegram (VPN кабинет). Мобильный фокус. - -**Текущий стек:** -- React + Vite + TypeScript -- Tailwind CSS -- React Query для данных - -**Ключевые файлы:** -- `tailwind.config.js` — цвета через CSS переменные -- `src/styles/globals.css` — компонентные классы (.card, .btn, etc) -- `src/components/layout/Layout.tsx` — шапка + таббар -- `src/pages/Dashboard.tsx` — главный экран (монстр, но логику не трогаем) - -**Темы:** Есть dark и light (champagne). Bento-стили должны работать в обеих. - ---- - -## Следующие этапы (Phase 2) - -### Этап 5: Остальные страницы пользователя -> Применить bento-стили к основным страницам - -| # | Страница | Приоритет | Объём | Статус | -|---|----------|-----------|-------|--------| -| 5.1 | Subscription.tsx | High | Большая — формы, тарифы, карточки | `[x]` | -| 5.2 | Balance.tsx | High | Средняя — баланс, транзакции, методы оплаты | `[x]` | -| 5.3 | Referral.tsx | Medium | Средняя — статистика, ссылка, условия | `[x]` | -| 5.4 | Support.tsx | Medium | Малая — тикеты | `[x]` | -| 5.5 | Profile.tsx | Low | Малая — настройки | `[x]` | -| 5.6 | Info.tsx | Low | Малая — статичный контент | `[x]` | - -### Этап 6: Модалки -> Обновить модальные окна в bento-стиле - -| # | Компонент | Статус | Заметки | -|---|-----------|--------|---------| -| 6.1 | ConnectionModal | `[x]` | ✅ z-[60], rounded-3xl, backdrop-blur-xl (desktop + mobile) | -| 6.2 | TopUpModal | `[x]` | ✅ z-[60], rounded-3xl, backdrop-blur-xl | -| 6.3 | InsufficientBalancePrompt | `[x]` | ✅ z-[60], rounded-3xl, backdrop-blur-xl (PaymentMethodModal тоже) | -| 6.4 | Wheel Result Modal | `[x]` | ✅ z-[60] | -| 6.5 | Contests Modal | `[x]` | ✅ z-[60], bento-card, backdrop-blur-sm | - -**Выборка модалок (select/close overlap):** Не найдено — все селекты в Subscription.tsx используют кнопки вместо ` onChange(parseInt(e.target.value))} + className="w-full h-2.5 rounded-full appearance-none cursor-pointer" + style={{ background: gradient }} + /> + + ) +} + +function CompactColorInput({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (color: string) => void +}) { + const [localValue, setLocalValue] = useState(value) + const [isEditing, setIsEditing] = useState(false) + + useEffect(() => { + setLocalValue(value) + }, [value]) + + const handleChange = (newValue: string) => { + let formatted = newValue.toUpperCase() + if (!formatted.startsWith('#')) { + formatted = '#' + formatted + } + setLocalValue(formatted) + if (isValidHex(formatted)) { + onChange(formatted) + } + } + + const handleBlur = () => { + setIsEditing(false) + if (!isValidHex(localValue)) { + setLocalValue(value) + } + } + + return ( +
+ + )} +
+ + ) +} + +function CollapsibleSection({ + title, + icon, + isOpen, + onToggle, + children, + badge, +}: { + title: string + icon: React.ReactNode + isOpen: boolean + onToggle: () => void + children: React.ReactNode + badge?: string +}) { + return ( +
+ + +
+
+
{children}
+
+
+
+ ) +} + +export function ThemeBentoPicker({ + currentColors, + onColorsChange, + onSave, + isSaving, +}: ThemeBentoPickerProps) { + const { t } = useTranslation() + + const [hsl, setHsl] = useState(() => hexToHsl(currentColors.accent)) + const [hexInput, setHexInput] = useState(currentColors.accent) + const [hasChanges, setHasChanges] = useState(false) + + const [isAccentOpen, setIsAccentOpen] = useState(false) + const [isDarkOpen, setIsDarkOpen] = useState(false) + const [isLightOpen, setIsLightOpen] = useState(false) + const [isStatusOpen, setIsStatusOpen] = useState(false) + + const selectedPresetId = useMemo(() => { + const match = COLOR_PRESETS.find( + (p) => + p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() && + p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() && + p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase() + ) + return match?.id ?? null + }, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]) + + useEffect(() => { + setHsl(hexToHsl(currentColors.accent)) + setHexInput(currentColors.accent) + }, [currentColors.accent]) + + const updateColor = useCallback( + (key: keyof ThemeColors, value: string) => { + const newColors = { ...currentColors, [key]: value } + onColorsChange(newColors) + applyThemeColors(newColors) + setHasChanges(true) + }, + [currentColors, onColorsChange] + ) + + const updateAccentFromHsl = useCallback( + (newHsl: HSLColor) => { + setHsl(newHsl) + const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l) + setHexInput(newHex) + updateColor('accent', newHex) + }, + [updateColor] + ) + + const handleHexInputChange = (value: string) => { + setHexInput(value) + if (isValidHex(value)) { + const newHsl = hexToHsl(value) + setHsl(newHsl) + updateColor('accent', value) + } + } + + const handlePresetSelect = (preset: ColorPreset) => { + onColorsChange(preset.colors) + applyThemeColors(preset.colors) + setHasChanges(true) + } + + const hueGradient = useMemo(() => { + return `linear-gradient(to right, + hsl(0, ${hsl.s}%, ${hsl.l}%), + hsl(60, ${hsl.s}%, ${hsl.l}%), + hsl(120, ${hsl.s}%, ${hsl.l}%), + hsl(180, ${hsl.s}%, ${hsl.l}%), + hsl(240, ${hsl.s}%, ${hsl.l}%), + hsl(300, ${hsl.s}%, ${hsl.l}%), + hsl(360, ${hsl.s}%, ${hsl.l}%) + )` + }, [hsl.s, hsl.l]) + + const saturationGradient = useMemo(() => { + return `linear-gradient(to right, + hsl(${hsl.h}, 0%, ${hsl.l}%), + hsl(${hsl.h}, 100%, ${hsl.l}%) + )` + }, [hsl.h, hsl.l]) + + const lightnessGradient = useMemo(() => { + return `linear-gradient(to right, + hsl(${hsl.h}, ${hsl.s}%, 0%), + hsl(${hsl.h}, ${hsl.s}%, 50%), + hsl(${hsl.h}, ${hsl.s}%, 100%) + )` + }, [hsl.h, hsl.s]) + + return ( +
+
+

+ {t('admin.theme.quickPresets', 'Quick Presets')} +

+
+ {COLOR_PRESETS.map((preset, index) => ( +
+ handlePresetSelect(preset)} + /> +
+ ))} +
+
+ +
+

+ {t('admin.theme.customizeColors', 'Customize Colors')} +

+ + } + badge={hexInput.toUpperCase()} + isOpen={isAccentOpen} + onToggle={() => setIsAccentOpen(!isAccentOpen)} + > +
+
+
+
+ {hexInput.toUpperCase()} +
+
+ + updateAccentFromHsl({ ...hsl, h })} + max={360} + gradient={hueGradient} + suffix="°" + /> + + updateAccentFromHsl({ ...hsl, s })} + max={100} + gradient={saturationGradient} + suffix="%" + /> + + updateAccentFromHsl({ ...hsl, l })} + max={100} + gradient={lightnessGradient} + suffix="%" + /> + +
+ + handleHexInputChange(e.target.value)} + placeholder="#3b82f6" + maxLength={7} + className="input w-full text-sm font-mono uppercase" + /> +
+
+ + + } + isOpen={isDarkOpen} + onToggle={() => setIsDarkOpen(!isDarkOpen)} + > +
+ updateColor('darkBackground', c)} + /> + updateColor('darkSurface', c)} + /> + updateColor('darkText', c)} + /> + updateColor('darkTextSecondary', c)} + /> +
+
+ + } + isOpen={isLightOpen} + onToggle={() => setIsLightOpen(!isLightOpen)} + > +
+ updateColor('lightBackground', c)} + /> + updateColor('lightSurface', c)} + /> + updateColor('lightText', c)} + /> + updateColor('lightTextSecondary', c)} + /> +
+
+ + } + isOpen={isStatusOpen} + onToggle={() => setIsStatusOpen(!isStatusOpen)} + > +
+ updateColor('success', c)} + /> + updateColor('warning', c)} + /> + updateColor('error', c)} + /> +
+
+
+ +
+

+ {t('theme.preview', 'Preview')} +

+
+ + + {t('theme.success', 'Success')} + {t('theme.warning', 'Warning')} + {t('theme.error', 'Error')} +
+
+ + {hasChanges && ( +
+ +
+ )} +
+ ) +} diff --git a/src/data/colorPresets.ts b/src/data/colorPresets.ts new file mode 100644 index 0000000..f272f23 --- /dev/null +++ b/src/data/colorPresets.ts @@ -0,0 +1,179 @@ +import { ThemeColors } from '../types/theme' + +export interface ColorPreset { + id: string + name: string + nameRu: string + description: string + descriptionRu: string + colors: ThemeColors + // Preview colors for the card + preview: { + background: string + accent: string + text: string + } +} + +export const COLOR_PRESETS: ColorPreset[] = [ + { + id: 'electric-blue', + name: 'Electric Blue', + nameRu: 'Электрик', + description: 'Classic tech blue, reliable and clean', + descriptionRu: 'Классический технологичный синий', + colors: { + accent: '#3b82f6', + darkBackground: '#0a0f1a', + darkSurface: '#0f172a', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#f8fafc', + lightSurface: '#ffffff', + lightText: '#0f172a', + lightTextSecondary: '#64748b', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0a0f1a', + accent: '#3b82f6', + text: '#f1f5f9', + }, + }, + { + id: 'toxic-neon', + name: 'Toxic Neon', + nameRu: 'Токсичный неон', + description: 'Cyberpunk vibes, high energy', + descriptionRu: 'Киберпанк атмосфера, высокая энергия', + colors: { + accent: '#22c55e', + darkBackground: '#030712', + darkSurface: '#0a0f14', + darkText: '#e2e8f0', + darkTextSecondary: '#64748b', + lightBackground: '#f0fdf4', + lightSurface: '#ffffff', + lightText: '#052e16', + lightTextSecondary: '#166534', + success: '#22c55e', + warning: '#eab308', + error: '#ef4444', + }, + preview: { + background: '#030712', + accent: '#22c55e', + text: '#e2e8f0', + }, + }, + { + id: 'royal-purple', + name: 'Royal Purple', + nameRu: 'Королевский пурпур', + description: 'Premium, sophisticated, Stripe-like', + descriptionRu: 'Премиальный, утончённый, как Stripe', + colors: { + accent: '#8b5cf6', + darkBackground: '#0c0a14', + darkSurface: '#13111c', + darkText: '#f1f0f5', + darkTextSecondary: '#a1a1aa', + lightBackground: '#faf5ff', + lightSurface: '#ffffff', + lightText: '#1e1b29', + lightTextSecondary: '#6b21a8', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0c0a14', + accent: '#8b5cf6', + text: '#f1f0f5', + }, + }, + { + id: 'sunset-orange', + name: 'Sunset Orange', + nameRu: 'Закатный оранж', + description: 'Warm, energetic, action-oriented', + descriptionRu: 'Тёплый, энергичный, призыв к действию', + colors: { + accent: '#f97316', + darkBackground: '#0f0906', + darkSurface: '#1a120d', + darkText: '#fef3e2', + darkTextSecondary: '#a3a3a3', + lightBackground: '#fff7ed', + lightSurface: '#ffffff', + lightText: '#1c1917', + lightTextSecondary: '#c2410c', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0f0906', + accent: '#f97316', + text: '#fef3e2', + }, + }, + { + id: 'ocean-teal', + name: 'Ocean Teal', + nameRu: 'Океанский бирюзовый', + description: 'Calm, trustworthy, health-tech', + descriptionRu: 'Спокойный, надёжный, медтех', + colors: { + accent: '#14b8a6', + darkBackground: '#042f2e', + darkSurface: '#0d3d3b', + darkText: '#f0fdfa', + darkTextSecondary: '#5eead4', + lightBackground: '#f0fdfa', + lightSurface: '#ffffff', + lightText: '#134e4a', + lightTextSecondary: '#0f766e', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#042f2e', + accent: '#14b8a6', + text: '#f0fdfa', + }, + }, + { + id: 'champagne-gold', + name: 'Champagne Gold', + nameRu: 'Шампанское золото', + description: 'Luxury, premium, elegant', + descriptionRu: 'Роскошный, премиальный, элегантный', + colors: { + accent: '#b8860b', + darkBackground: '#0a0f1a', + darkSurface: '#0f172a', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#F7E7CE', + lightSurface: '#FEF9F0', + lightText: '#1F1A12', + lightTextSecondary: '#7D6B48', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#F7E7CE', + accent: '#b8860b', + text: '#1F1A12', + }, + }, +] + +export function getPresetById(id: string): ColorPreset | undefined { + return COLOR_PRESETS.find((preset) => preset.id === id) +} diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 69f1711..cbe668b 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -7,8 +7,8 @@ import { brandingApi, setCachedBranding } from '../api/branding' import { setCachedAnimationEnabled } from '../components/AnimatedBackground' import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp' import { themeColorsApi } from '../api/themeColors' -import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme' -import { ColorPicker } from '../components/ColorPicker' +import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES, ThemeColors } from '../types/theme' +import { ThemeBentoPicker } from '../components/ThemeBentoPicker' import { applyThemeColors } from '../hooks/useThemeColors' import { updateEnabledThemesCache } from '../hooks/useTheme' @@ -858,6 +858,7 @@ export default function AdminSettings() { const [searchQuery, setSearchQuery] = useState('') const [editingName, setEditingName] = useState(false) const [newName, setNewName] = useState('') + const [localColors, setLocalColors] = useState(null) const fileInputRef = useRef(null) // Branding query and mutations @@ -1355,113 +1356,18 @@ export default function AdminSettings() { Включите нужные темы для пользователей. Минимум одна тема должна быть активна.

- {/* Accent Color */} - updateColorsMutation.mutate({ accent: color })} - disabled={updateColorsMutation.isPending} + + setLocalColors(colors)} + onSave={() => { + if (localColors) { + updateColorsMutation.mutate(localColors) + setLocalColors(null) + } + }} + isSaving={updateColorsMutation.isPending} /> - - {/* Dark Theme Section */} -
-

{t('theme.darkTheme')}

-
- updateColorsMutation.mutate({ darkBackground: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkSurface: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkText: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkTextSecondary: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Light Theme Section */} -
-

{t('theme.lightTheme')}

-
- updateColorsMutation.mutate({ lightBackground: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightSurface: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightText: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightTextSecondary: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Status Colors */} -
-

{t('theme.statusColors')}

-
- updateColorsMutation.mutate({ success: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ warning: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ error: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Preview */} -
-

{t('theme.preview')}

-
- - - {t('theme.success')} - {t('theme.warning')} - {t('theme.error')} -
-
diff --git a/src/utils/colorConversion.ts b/src/utils/colorConversion.ts new file mode 100644 index 0000000..acb39fb --- /dev/null +++ b/src/utils/colorConversion.ts @@ -0,0 +1,102 @@ +export interface HSLColor { + h: number + s: number + l: number +} + +export interface RGBColor { + r: number + g: number + b: number +} + +export function hexToRgb(hex: string): RGBColor { + if (hex.length === 4) { + hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3] + } + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return { r, g, b } +} + +export function rgbToHex(r: number, g: number, b: number): string { + return ( + '#' + + [r, g, b] + .map((x) => { + const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16) + return hex.length === 1 ? '0' + hex : hex + }) + .join('') + ) +} + +export function hexToHsl(hex: string): HSLColor { + const { r, g, b } = hexToRgb(hex) + const rNorm = r / 255 + const gNorm = g / 255 + const bNorm = b / 255 + + const max = Math.max(rNorm, gNorm, bNorm) + const min = Math.min(rNorm, gNorm, bNorm) + let h = 0 + let s = 0 + const l = (max + min) / 2 + + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + + switch (max) { + case rNorm: + h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6 + break + case gNorm: + h = ((bNorm - rNorm) / d + 2) / 6 + break + case bNorm: + h = ((rNorm - gNorm) / d + 4) / 6 + break + } + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + } +} + +export function hslToRgb(h: number, s: number, l: number): RGBColor { + const sNorm = s / 100 + const lNorm = l / 100 + + const a = sNorm * Math.min(lNorm, 1 - lNorm) + const f = (n: number) => { + const k = (n + h / 30) % 12 + const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + return Math.round(255 * color) + } + + return { r: f(0), g: f(8), b: f(4) } +} + +export function hslToHex(h: number, s: number, l: number): string { + const { r, g, b } = hslToRgb(h, s, l) + return rgbToHex(r, g, b) +} + +export function isValidHex(hex: string): boolean { + return /^#[0-9A-Fa-f]{6}$/.test(hex) +} + +export function normalizeHex(hex: string): string { + if (!hex.startsWith('#')) { + hex = '#' + hex + } + if (hex.length === 4) { + hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3] + } + return hex.toLowerCase() +}