feat: Implement dynamic accent color selection and refactor appearance settings

This commit is contained in:
2025-11-29 00:49:24 -05:00
parent 10e1ca8396
commit c88e5d9d82
26 changed files with 1319 additions and 1235 deletions

274
src/lib/color-utils.ts Normal file
View File

@@ -0,0 +1,274 @@
type Oklch = {
l: number;
c: number;
h: number;
};
/**
* Converts a hexadecimal color string to an Oklch color object.
*
* @param {string} hex - The hexadecimal color string (e.g., "#RRGGBB", "RRGGBB", "#RGB", "RGB").
* @returns {Oklch} The Oklch color object.
* @throws {Error} If the hex color format is invalid.
*/
export function hexToOklch(hex: string): Oklch {
const rgb = hexToRgb(hex);
const linear_rgb = rgb.map(srgbToLinearRgb) as [number, number, number];
const xyz = linearRgbToXyz(linear_rgb);
const oklab = xyzToOklab(xyz);
const oklch = oklabToOklch(oklab);
return {
l: oklch[0] || 0,
c: oklch[1] || 0,
h: oklch[2] || 0,
};
}
export function generateAccentColors(hex: string) {
const base = hexToOklch(hex);
const light = {
"--background": `oklch(0.99 ${base.c * 0.05} ${base.h})`,
"--foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--card": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--card-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--popover": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--primary": `oklch(0.6 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
"--muted-foreground": `oklch(0.5 ${base.c * 0.4} ${base.h})`,
"--accent": `oklch(0.98 ${base.c * 0.6} ${base.h})`,
"--accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--destructive": "oklch(0.58 0.24 28)",
"--destructive-foreground": "oklch(0.98 0.01 230)",
"--success": "oklch(0.55 0.15 142)",
"--success-foreground": "oklch(0.98 0.01 230)",
"--warning": "oklch(0.65 0.15 38)",
"--warning-foreground": "oklch(0.2 0.03 230)",
"--border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
"--input": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
"--ring": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`,
"--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
"--sidebar-ring": `oklch(0.6 ${base.c} ${base.h})`,
"--navbar": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--navbar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--navbar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
};
const dark = {
"--background": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--card": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
"--card-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`,
"--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--primary": `oklch(0.7 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`,
"--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`,
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
"--accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--destructive": "oklch(0.7 0.19 22)",
"--destructive-foreground": "oklch(0.2 0.03 230)",
"--success": "oklch(0.6 0.15 142)",
"--success-foreground": "oklch(0.98 0.01 230)",
"--warning": "oklch(0.7 0.15 38)",
"--warning-foreground": "oklch(0.2 0.03 230)",
"--border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
"--input": `oklch(0.35 ${base.c * 0.4} ${base.h})`,
"--ring": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
"--sidebar-ring": `oklch(0.7 ${base.c} ${base.h})`,
"--navbar": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
"--navbar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--navbar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
};
return { light, dark };
}
/**
* Converts a hexadecimal color string to an array of R, G, B components (0-255).
* Supports "#RRGGBB", "RRGGBB", "#RGB", "RGB" formats.
* @param {string} hex - The hexadecimal color string.
* @returns {number[]} An array [r, g, b].
* @throws {Error} If the hex color format is invalid.
*/
function hexToRgb(hex: string): [number, number, number] {
let r = 0,
g = 0,
b = 0;
// Remove '#' if present
if (hex.startsWith("#")) {
hex = hex.slice(1);
}
// Handle 3-digit hex (e.g., "F0C" -> "FF00CC")
if (hex.length === 3) {
const chars = hex.split("");
if (
chars.length === 3 &&
chars.every((char) => /^[0-9A-Fa-f]$/.test(char))
) {
r = parseInt(chars[0]! + chars[0]!, 16);
g = parseInt(chars[1]! + chars[1]!, 16);
b = parseInt(chars[2]! + chars[2]!, 16);
} else {
throw new Error("Invalid 3-digit hex color format.");
}
}
// Handle 6-digit hex (e.g., "FF00CC")
else if (hex.length === 6) {
const rStr = hex.substring(0, 2);
const gStr = hex.substring(2, 4);
const bStr = hex.substring(4, 6);
if (
/^[0-9A-Fa-f]{2}$/.test(rStr) &&
/^[0-9A-Fa-f]{2}$/.test(gStr) &&
/^[0-9A-Fa-f]{2}$/.test(bStr)
) {
r = parseInt(rStr, 16);
g = parseInt(gStr, 16);
b = parseInt(bStr, 16);
} else {
throw new Error("Invalid 6-digit hex color format.");
}
} else {
throw new Error("Invalid hex color format. Use #RRGGBB or #RGB.");
}
return [r, g, b];
}
/**
* Converts an sRGB component (0-255) to a linear sRGB component (0-1).
* @param {number} c - The sRGB component value (0-255).
* @returns {number} The linear sRGB component value (0-1).
*/
function srgbToLinearRgb(c: number) {
c /= 255; // Normalize to [0, 1]
// Apply the sRGB gamma correction formula.
if (c <= 0.04045) {
return c / 12.92;
} else {
return Math.pow((c + 0.055) / 1.055, 2.4);
}
}
/**
* Multiplies a 3x3 matrix by a 3-element vector.
* @param {number[][]} matrix - The 3x3 matrix.
* @param {number[]} vector - The 3-element vector.
* @returns {number[]} The resulting 3-element vector.
*/
function multiplyMatrix(
matrix: number[][],
vector: number[],
): [number, number, number] {
const result = new Array(matrix.length).fill(0);
for (let i = 0; i < matrix.length; i++) {
for (let j = 0; j < vector.length; j++) {
result[i]! += matrix[i]![j]! * vector[j]!;
}
}
return [result[0]!, result[1]!, result[2]!];
}
/**
* Converts linear sRGB values to CIE XYZ values (D65 white point).
* @param {number[]} rgb_linear - An array [r, g, b] of linear sRGB components (0-1).
* @returns {number[]} An array [X, Y, Z] of CIE XYZ components.
*/
function linearRgbToXyz(
rgb_linear: [number, number, number],
): [number, number, number] {
// Standard sRGB to XYZ D65 conversion matrix.
const M_srgb_to_xyz = [
[0.4123908, 0.35758434, 0.18048079],
[0.21263901, 0.71516868, 0.07219232],
[0.01933082, 0.11919478, 0.95053215],
];
return multiplyMatrix(M_srgb_to_xyz, rgb_linear);
}
/**
* Converts CIE XYZ values to Oklab values.
* @param {number[]} xyz - An array [X, Y, Z] of CIE XYZ components.
* @returns {number[]} An array [L, a, b] of Oklab components.
*/
function xyzToOklab(xyz: [number, number, number]): [number, number, number] {
// Convert XYZ to LMS (linear cone responses).
const M_xyz_to_lms = [
[0.81890226, 0.03298366, 0.05591174],
[0.36186742, 0.638518, 0.00083942],
[0, 0, 0.82521],
];
const lms = multiplyMatrix(M_xyz_to_lms, xyz);
// Apply cube root non-linearity to LMS values.
const lms_prime = lms.map((val) => Math.cbrt(val)) as [
number,
number,
number,
];
// Convert LMS' to Oklab.
const M_lms_prime_to_oklab = [
[0.2104542553, 0.793617785, -0.0040720468],
[1.9779984951, -2.428592205, 0.4505937099],
[0.0259040371, 0.7827717662, -0.808675766],
];
return multiplyMatrix(M_lms_prime_to_oklab, lms_prime);
}
/**
* Converts Oklab values to Oklch values.
* @param {number[]} oklab - An array [L, a, b] of Oklab components (L in 0-1).
* @returns {number[]} An array [L, C, h] of Oklch components (L in 0-100, h in degrees).
*/
function oklabToOklch(oklab: number[]): [number, number, number] {
const L = oklab[0] ?? 0; // Oklab L is 0-1
const a = oklab[1] ?? 0;
const b = oklab[2] ?? 0;
const C = Math.sqrt(a * a + b * b); // Chroma
let h = Math.atan2(b, a) * (180 / Math.PI); // Hue in degrees
// Normalize hue to [0, 360)
if (h < 0) {
h += 360;
}
// Oklch L is typically scaled to 0-100.
return [L, C, h];
}