Add color palette generator

This commit is contained in:
Wendelin 2025-07-23 15:07:58 +02:00
parent 07c7b07362
commit 0a33240d7a
No known key found for this signature in database
5 changed files with 149 additions and 0 deletions

View File

@ -101,6 +101,7 @@
"comlink": "4.4.2",
"core-js": "3.44.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
"deep-clone-simple": "1.1.1",
@ -164,6 +165,7 @@
"@types/chromecast-caf-receiver": "6.0.22",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.20",

118
src/common/color/palette.ts Normal file
View File

@ -0,0 +1,118 @@
import { formatHex, oklch, wcagLuminance, type Oklch } from "culori";
export const DARK_COLOR_STEPS = [5, 10, 20, 30, 40];
export const LIGHT_COLOR_STEPS = [60, 70, 80, 90, 95];
const MIN_LUMINANCE = 0.3;
const MAX_LUMINANCE = 0.6;
/**
* Normalizes the luminance of a given color to ensure it falls within the specified minimum and maximum luminance range.
* This helps to keep everything readable and accessible, especially for text and UI elements.
*
* This function converts the input color to the OKLCH color space, calculates its luminance,
* and adjusts the lightness component if the luminance is outside the allowed range.
* The adjustment is performed using a binary search to find the appropriate lightness value.
* If the color is already within the range, it is returned unchanged.
*
* @param color - HEX color string
* @returns The normalized color as a hex string, or the original color if normalization is not needed.
* @throws If the provided color is invalid or cannot be parsed.
*/
export const normalizeLuminance = (color: string): string => {
const baseOklch = oklch(color);
if (baseOklch === undefined) {
throw new Error("Invalid color provided");
}
const luminance = wcagLuminance(baseOklch);
if (luminance >= MIN_LUMINANCE && luminance <= MAX_LUMINANCE) {
return color;
}
const targetLuminance =
luminance < MIN_LUMINANCE ? MIN_LUMINANCE : MAX_LUMINANCE;
function findLightness(lowL = 0, highL = 1, iterations = 10) {
if (iterations <= 0) {
return (lowL + highL) / 2;
}
const midL = (lowL + highL) / 2;
const testColor = { ...baseOklch, l: midL } as Oklch;
const testLuminance = wcagLuminance(testColor);
if (Math.abs(testLuminance - targetLuminance) < 0.01) {
return midL;
}
if (testLuminance < targetLuminance) {
return findLightness(midL, highL, iterations--);
}
return findLightness(lowL, midL, iterations--);
}
baseOklch.l = findLightness();
return formatHex(baseOklch) || color;
};
/**
* Generates a color palette based on a base color using the OKLCH color space.
*
* The palette consists of multiple shades, both lighter and darker than the base color,
* calculated by adjusting the lightness and chroma values. Each shade is labeled and
* returned as a tuple containing the shade name and its hexadecimal color value.
*
* @param baseColor - The base color in a HEX format.
* @param label - A string label used to name each color variant in the palette.
* @param steps - An array of numbers representing the percentage steps for generating shades (default: [5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95]).
* @returns An array of tuples, each containing the shade name and its corresponding hex color value.
* @throws If the provided base color is invalid or cannot be parsed by the `oklch` function.
*/
export const generateColorPalette = (
baseColor: string,
label: string,
steps = [5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95]
) => {
const baseOklch = oklch(baseColor);
if (baseOklch === undefined) {
throw new Error("Invalid base color provided");
}
return steps.map((step) => {
const name = `color-${label}-${step}`;
// Base color at 50%
if (step === 50) {
return [name, formatHex(baseOklch)];
}
// For darker shades (below 50%)
if (step < 50) {
const darkFactor = step / 50;
// Adjust lightness and chroma to create darker variants
const darker = {
...baseOklch,
l: baseOklch.l * darkFactor, // darkening
c: baseOklch.c * (0.9 + 0.1 * darkFactor), // Slightly adjust chroma
};
return [name, formatHex(darker)];
}
// For lighter shades (above 50%)
const lightFactor = (step - 50) / 45; // Normalized from 0 to 1
// Adjust lightness and reduce chroma for lighter variants
const lighter = {
...baseOklch,
l: Math.min(1, baseOklch.l + (1 - baseOklch.l) * lightFactor), // Increase lightness
c: baseOklch.c * Math.max(0, 1 - lightFactor * 0.7), // Gradually reduce chroma
};
return [name, formatHex(lighter)];
});
};

View File

@ -11,6 +11,7 @@ import {
} from "../color/convert-color";
import { hexBlend } from "../color/hex";
import { labBrighten, labDarken } from "../color/lab";
import { generateColorPalette } from "../color/palette";
import { rgbContrast } from "../color/rgb";
interface ProcessedTheme {
@ -75,6 +76,11 @@ export const applyThemesOnElement = (
const labPrimaryColor = rgb2lab(rgbPrimaryColor);
themeRules["primary-color"] = primaryColor;
const rgbLightPrimaryColor = lab2rgb(labBrighten(labPrimaryColor));
generateColorPalette(primaryColor, "primary").forEach(([key, color]) => {
themeRules[key] = color;
});
themeRules["light-primary-color"] = rgb2hex(rgbLightPrimaryColor);
themeRules["dark-primary-color"] = lab2hex(labDarken(labPrimaryColor));
themeRules["text-primary-color"] =

View File

@ -3,6 +3,7 @@ import "@material/mwc-button/mwc-button";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { normalizeLuminance } from "../../common/color/palette";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-formfield";
import "../../components/ha-list-item";
@ -171,6 +172,12 @@ export class HaPickThemeRow extends LitElement {
private _handleColorChange(ev: CustomEvent) {
const target = ev.target as any;
// normalize primary color if needed for contrast
if (target.name === "primaryColor") {
target.value = normalizeLuminance(target.value);
}
fireEvent(this, "settheme", { [target.name]: target.value });
}

View File

@ -4467,6 +4467,13 @@ __metadata:
languageName: node
linkType: hard
"@types/culori@npm:4":
version: 4.0.0
resolution: "@types/culori@npm:4.0.0"
checksum: 10/62a9058d6125fe489ca1e7df27ac9837ea7a34c772b8bed8e5e00177b141574830efaa0c93363e9532878490d3245a9c9c8183ebee181a450097584af0cfefc1
languageName: node
linkType: hard
"@types/deep-eql@npm:*":
version: 4.0.2
resolution: "@types/deep-eql@npm:4.0.2"
@ -7033,6 +7040,13 @@ __metadata:
languageName: node
linkType: hard
"culori@npm:4.0.2":
version: 4.0.2
resolution: "culori@npm:4.0.2"
checksum: 10/9d297ca5c6fc86b2637200e1d9edb5a7d1015a2e978a6748781ef53c9577269ce1df96dc0925b0002239c85665ba881a3d580564dfd2807c65ee65dc2e829849
languageName: node
linkType: hard
"data-urls@npm:^5.0.0":
version: 5.0.0
resolution: "data-urls@npm:5.0.0"
@ -9378,6 +9392,7 @@ __metadata:
"@types/chromecast-caf-receiver": "npm:6.0.22"
"@types/chromecast-caf-sender": "npm:1.0.11"
"@types/color-name": "npm:2.0.0"
"@types/culori": "npm:4"
"@types/html-minifier-terser": "npm:7.0.2"
"@types/js-yaml": "npm:4.0.9"
"@types/leaflet": "npm:1.9.20"
@ -9407,6 +9422,7 @@ __metadata:
comlink: "npm:4.4.2"
core-js: "npm:3.44.0"
cropperjs: "npm:1.6.2"
culori: "npm:4.0.2"
date-fns: "npm:4.1.0"
date-fns-tz: "npm:3.2.0"
deep-clone-simple: "npm:1.1.1"