diff --git a/package.json b/package.json index 17bdd58ef3..31f2e5d9aa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/color/palette.ts b/src/common/color/palette.ts new file mode 100644 index 0000000000..4a6cafdeec --- /dev/null +++ b/src/common/color/palette.ts @@ -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)]; + }); +}; diff --git a/src/common/dom/apply_themes_on_element.ts b/src/common/dom/apply_themes_on_element.ts index b986e053a8..fe1eac8d4b 100644 --- a/src/common/dom/apply_themes_on_element.ts +++ b/src/common/dom/apply_themes_on_element.ts @@ -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"] = diff --git a/src/panels/profile/ha-pick-theme-row.ts b/src/panels/profile/ha-pick-theme-row.ts index edd4cece83..4c8cb11d57 100644 --- a/src/panels/profile/ha-pick-theme-row.ts +++ b/src/panels/profile/ha-pick-theme-row.ts @@ -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 }); } diff --git a/yarn.lock b/yarn.lock index 949b81347f..b3743d35d2 100644 --- a/yarn.lock +++ b/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"