mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-30 04:36:36 +00:00
Add color palette generator
This commit is contained in:
parent
07c7b07362
commit
0a33240d7a
@ -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
118
src/common/color/palette.ts
Normal 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)];
|
||||
});
|
||||
};
|
@ -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"] =
|
||||
|
@ -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 });
|
||||
}
|
||||
|
||||
|
16
yarn.lock
16
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user