MaiBot/dashboard/src/lib/theme/palette.ts

204 lines
5.1 KiB
TypeScript

import type { ColorTokens } from './tokens'
type HSL = {
h: number
s: number
l: number
}
const clamp = (value: number, min: number, max: number): number => {
if (value < min) return min
if (value > max) return max
return value
}
const roundToTenth = (value: number): number => Math.round(value * 10) / 10
const wrapHue = (value: number): number => ((value % 360) + 360) % 360
export const parseHSL = (hslStr: string): HSL => {
const cleaned = hslStr
.trim()
.replace(/^hsl\(/i, '')
.replace(/\)$/i, '')
.replace(/,/g, ' ')
const parts = cleaned.split(/\s+/).filter(Boolean)
const rawH = parts[0] ?? '0'
const rawS = parts[1] ?? '0%'
const rawL = parts[2] ?? '0%'
const h = Number.parseFloat(rawH)
const s = Number.parseFloat(rawS.replace('%', ''))
const l = Number.parseFloat(rawL.replace('%', ''))
return {
h: Number.isNaN(h) ? 0 : h,
s: Number.isNaN(s) ? 0 : s,
l: Number.isNaN(l) ? 0 : l,
}
}
export const formatHSL = (h: number, s: number, l: number): string => {
const safeH = roundToTenth(wrapHue(h))
const safeS = roundToTenth(clamp(s, 0, 100))
const safeL = roundToTenth(clamp(l, 0, 100))
return `${safeH} ${safeS}% ${safeL}%`
}
export const hexToHSL = (hex: string): string => {
let cleaned = hex.trim().replace('#', '')
if (cleaned.length === 3) {
cleaned = cleaned
.split('')
.map((char) => `${char}${char}`)
.join('')
}
if (cleaned.length !== 6) {
return formatHSL(0, 0, 0)
}
const r = Number.parseInt(cleaned.slice(0, 2), 16) / 255
const g = Number.parseInt(cleaned.slice(2, 4), 16) / 255
const b = Number.parseInt(cleaned.slice(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const delta = max - min
const l = (max + min) / 2
let h = 0
let s = 0
if (delta !== 0) {
s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min)
switch (max) {
case r:
h = (g - b) / delta + (g < b ? 6 : 0)
break
case g:
h = (b - r) / delta + 2
break
case b:
h = (r - g) / delta + 4
break
default:
break
}
h *= 60
}
return formatHSL(h, s * 100, l * 100)
}
export const adjustLightness = (hsl: string, amount: number): string => {
const { h, s, l } = parseHSL(hsl)
return formatHSL(h, s, l + amount)
}
export const adjustSaturation = (hsl: string, amount: number): string => {
const { h, s, l } = parseHSL(hsl)
return formatHSL(h, s + amount, l)
}
export const rotateHue = (hsl: string, degrees: number): string => {
const { h, s, l } = parseHSL(hsl)
return formatHSL(h + degrees, s, l)
}
const setLightness = (hsl: string, lightness: number): string => {
const { h, s } = parseHSL(hsl)
return formatHSL(h, s, lightness)
}
const setSaturation = (hsl: string, saturation: number): string => {
const { h, l } = parseHSL(hsl)
return formatHSL(h, saturation, l)
}
const getReadableForeground = (hsl: string): string => {
const { h, s, l } = parseHSL(hsl)
const neutralSaturation = clamp(s * 0.15, 6, 20)
return l > 60
? formatHSL(h, neutralSaturation, 10)
: formatHSL(h, neutralSaturation, 96)
}
export const generatePalette = (accentHSL: string, isDark: boolean): ColorTokens => {
const accent = parseHSL(accentHSL)
const primary = formatHSL(accent.h, accent.s, accent.l)
const background = isDark ? '222.2 84% 4.9%' : '0 0% 100%'
const foreground = isDark ? '210 40% 98%' : '222.2 84% 4.9%'
const secondary = formatHSL(
accent.h,
clamp(accent.s * 0.35, 8, 40),
isDark ? 17.5 : 96,
)
const muted = formatHSL(
accent.h,
clamp(accent.s * 0.12, 2, 18),
isDark ? 17.5 : 96,
)
const accentVariant = formatHSL(
accent.h + 35,
clamp(accent.s * 0.6, 20, 85),
isDark ? clamp(accent.l * 0.6 + 8, 25, 60) : clamp(accent.l * 0.8 + 14, 40, 75),
)
const destructive = formatHSL(
0,
clamp(accent.s, 60, 90),
isDark ? 30.6 : 60.2,
)
const border = formatHSL(
accent.h,
clamp(accent.s * 0.2, 5, 25),
isDark ? 17.5 : 91.4,
)
const mutedForeground = setSaturation(
setLightness(muted, isDark ? 65.1 : 46.9),
clamp(accent.s * 0.2, 10, 30),
)
const chartBase = formatHSL(accent.h, accent.s, accent.l)
const chartSteps = [0, 72, 144, 216, 288]
const charts = chartSteps.map((step) => rotateHue(chartBase, step))
const card = adjustLightness(background, isDark ? 2 : -1)
const popover = adjustLightness(background, isDark ? 3 : -0.5)
return {
primary,
'primary-foreground': getReadableForeground(primary),
'primary-gradient': 'none',
secondary,
'secondary-foreground': getReadableForeground(secondary),
muted,
'muted-foreground': mutedForeground,
accent: accentVariant,
'accent-foreground': getReadableForeground(accentVariant),
destructive,
'destructive-foreground': getReadableForeground(destructive),
background,
foreground,
card,
'card-foreground': foreground,
popover,
'popover-foreground': foreground,
border,
input: border,
ring: primary,
'chart-1': charts[0],
'chart-2': charts[1],
'chart-3': charts[2],
'chart-4': charts[3],
'chart-5': charts[4],
}
}