diff --git a/apps/yaak-client/hooks/usePreferredAppearance.ts b/apps/yaak-client/hooks/usePreferredAppearance.ts index 5d189751..8db6ec14 100644 --- a/apps/yaak-client/hooks/usePreferredAppearance.ts +++ b/apps/yaak-client/hooks/usePreferredAppearance.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import type { Appearance } from "../lib/theme/appearance"; -import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance"; +import type { Appearance } from "@yaakapp-internal/theme"; +import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme"; export function usePreferredAppearance() { const [preferredAppearance, setPreferredAppearance] = useState(getCSSAppearance()); diff --git a/apps/yaak-client/hooks/useResolvedAppearance.ts b/apps/yaak-client/hooks/useResolvedAppearance.ts index f7a21cec..f5789a5a 100644 --- a/apps/yaak-client/hooks/useResolvedAppearance.ts +++ b/apps/yaak-client/hooks/useResolvedAppearance.ts @@ -1,6 +1,6 @@ import { settingsAtom } from "@yaakapp-internal/models"; +import { resolveAppearance } from "@yaakapp-internal/theme"; import { useAtomValue } from "jotai"; -import { resolveAppearance } from "../lib/theme/appearance"; import { usePreferredAppearance } from "./usePreferredAppearance"; export function useResolvedAppearance() { diff --git a/apps/yaak-client/hooks/useResolvedTheme.ts b/apps/yaak-client/hooks/useResolvedTheme.ts index 54f6b72c..35349a80 100644 --- a/apps/yaak-client/hooks/useResolvedTheme.ts +++ b/apps/yaak-client/hooks/useResolvedTheme.ts @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { settingsAtom } from "@yaakapp-internal/models"; import { useAtomValue } from "jotai"; -import { getResolvedTheme, getThemes } from "../lib/theme/themes"; +import { getResolvedTheme, getThemes } from "../lib/themes"; import { usePluginsKey } from "./usePlugins"; import { usePreferredAppearance } from "./usePreferredAppearance"; diff --git a/apps/yaak-client/lib/theme/appearance.ts b/apps/yaak-client/lib/theme/appearance.ts deleted file mode 100644 index 00448544..00000000 --- a/apps/yaak-client/lib/theme/appearance.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type { Appearance } from "@yaakapp-internal/theme"; -export { - getCSSAppearance, - getWindowAppearance, - resolveAppearance, - subscribeToPreferredAppearance, - subscribeToWindowAppearanceChange, -} from "@yaakapp-internal/theme"; diff --git a/apps/yaak-client/lib/theme/window.ts b/apps/yaak-client/lib/theme/window.ts deleted file mode 100644 index f946ad9a..00000000 --- a/apps/yaak-client/lib/theme/window.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme"; -export { - addThemeStylesToDocument, - applyThemeToDocument, - completeTheme, - getThemeCSS, - indent, - setThemeOnDocument, -} from "@yaakapp-internal/theme"; diff --git a/apps/yaak-client/lib/theme/yaakColor.ts b/apps/yaak-client/lib/theme/yaakColor.ts deleted file mode 100644 index 513e50b2..00000000 --- a/apps/yaak-client/lib/theme/yaakColor.ts +++ /dev/null @@ -1 +0,0 @@ -export { YaakColor } from "@yaakapp-internal/theme"; diff --git a/apps/yaak-client/lib/theme/themes.ts b/apps/yaak-client/lib/themes.ts similarity index 79% rename from apps/yaak-client/lib/theme/themes.ts rename to apps/yaak-client/lib/themes.ts index f3d0233f..eda4b01e 100644 --- a/apps/yaak-client/lib/theme/themes.ts +++ b/apps/yaak-client/lib/themes.ts @@ -1,10 +1,11 @@ import type { GetThemesResponse } from "@yaakapp-internal/plugins"; -import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; -import { invokeCmd } from "../tauri"; -import type { Appearance } from "./appearance"; -import { resolveAppearance } from "./appearance"; - -export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; +import { + defaultDarkTheme, + defaultLightTheme, + resolveAppearance, + type Appearance, +} from "@yaakapp-internal/theme"; +import { invokeCmd } from "./tauri"; export async function getThemes() { const themes = (await invokeCmd("cmd_get_themes")).flatMap((t) => t.themes); diff --git a/apps/yaak-client/theme.ts b/apps/yaak-client/theme.ts index 99a8ae8e..c01446b1 100644 --- a/apps/yaak-client/theme.ts +++ b/apps/yaak-client/theme.ts @@ -2,11 +2,14 @@ import { listen } from "@tauri-apps/api/event"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { setWindowTheme } from "@yaakapp-internal/mac-window"; import type { ModelPayload } from "@yaakapp-internal/models"; +import type { Appearance } from "@yaakapp-internal/theme"; +import { + applyThemeToDocument, + getCSSAppearance, + subscribeToPreferredAppearance, +} from "@yaakapp-internal/theme"; import { getSettings } from "./lib/settings"; -import type { Appearance } from "./lib/theme/appearance"; -import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance"; -import { getResolvedTheme } from "./lib/theme/themes"; -import { applyThemeToDocument } from "@yaakapp-internal/theme"; +import { getResolvedTheme } from "./lib/themes"; // NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want // a good appearance guess so we're not waiting too long diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 1f6bb6f9..4ff15227 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -12,6 +12,9 @@ export type { DocumentPlatform, YaakColorKey, YaakColors, YaakTheme } from "./wi export { addThemeStylesToDocument, applyThemeToDocument, + completeColorVariables, + completeFullColorVariables, + completePartialColorVariables, completeTheme, getThemeCSS, indent, diff --git a/packages/theme/src/window.ts b/packages/theme/src/window.ts index 2fc9988b..ad8fb6cd 100644 --- a/packages/theme/src/window.ts +++ b/packages/theme/src/window.ts @@ -47,18 +47,10 @@ export type YaakTheme = { export type YaakColorKey = keyof ThemeComponentColors; export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown"; -type ComponentName = keyof NonNullable; +type ComponentName = keyof NonNullable; type CSSVariables = Record; -function themeVariables( - theme: Theme, - component?: ComponentName, - base?: CSSVariables, -): CSSVariables | null { - const cmp = - component == null - ? theme.base - : (theme.components?.[component] ?? ({} as ThemeComponentColors)); +export function completeFullColorVariables(theme: Theme, cmp: Partial): CSSVariables { const color = (value: string | undefined) => yc(theme, value); const vars: CSSVariables = { surface: cmp.surface, @@ -66,12 +58,12 @@ function themeVariables( surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(), backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(), selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(), - border: cmp.border ?? color(cmp.surface)?.lift(0.11)?.css(), - borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06)?.css(), - borderFocus: color(cmp.info)?.translucify(0.5)?.css(), + border: cmp.border, + borderSubtle: cmp.borderSubtle, + borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(), text: cmp.text, - textSubtle: cmp.textSubtle ?? color(cmp.text)?.lower(0.2)?.css(), - textSubtlest: cmp.textSubtlest ?? color(cmp.text)?.lower(0.3)?.css(), + textSubtle: cmp.textSubtle, + textSubtlest: cmp.textSubtlest, shadow: cmp.shadow ?? YaakColor.black() @@ -86,95 +78,126 @@ function themeVariables( danger: cmp.danger, }; - for (const [key, value] of Object.entries(vars)) { - if (!value && base?.[key as YaakColorKey]) { - vars[key as YaakColorKey] = base[key as YaakColorKey]; - } - } + const themeColor = (value: string) => new YaakColor(value, theme.dark ? "dark" : "light"); + const themeSurface = themeColor(theme.dark ? "oklch(23% 0 0)" : "oklch(100% 0 0)"); + const surface = themeColor(vars.surface ?? themeSurface.css()); + const reference = surface.compositeOver(themeSurface); + const seed = themeColor(vars.surface ?? vars.surfaceHighlight ?? vars.border ?? surface.css()); + const textBase = seed.desaturate(0.6).opacify(1); + const borderBase = seed.opacify(1); + const text = vars.text ?? textBase.withContrast(reference, 11).css(); + const textColor = themeColor(text); - return vars; + return normalizeColorVariables(theme, { + ...vars, + text, + textSubtle: vars.textSubtle ?? textColor.lower(0.2).css(), + textSubtlest: vars.textSubtlest ?? textColor.lower(0.4).css(), + border: vars.border ?? borderBase.desaturate(0.2).withContrast(reference, 3).css(), + borderSubtle: + vars.borderSubtle ?? borderBase.desaturate(0.2).withContrast(reference, 1.2).css(), + }); } -function templateTagColorVariables(color: YaakColor | null): Partial { - if (color == null) return {}; +export function completePartialColorVariables( + theme: Theme, + cmp: Partial, +): CSSVariables { + const color = (value: string | undefined) => yc(theme, value); + const text = color(cmp.text); - return { - text: color.lift(0.7).css(), - textSubtle: color.lift(0.4).css(), + return normalizeColorVariables(theme, { + surface: cmp.surface, + surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(), + surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(), + backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(), + selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(), + border: cmp.border ?? color(cmp.surface)?.lift(0.11).css(), + borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06).css(), + borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5).css(), + text: cmp.text, + textSubtle: cmp.textSubtle ?? text?.lower(0.3).css(), + textSubtlest: cmp.textSubtlest ?? text?.lower(0.5).css(), + shadow: + cmp.shadow ?? + YaakColor.black() + .translucify(theme.dark ? 0.7 : 0.93) + .css(), + primary: cmp.primary, + secondary: cmp.secondary, + info: cmp.info, + success: cmp.success, + notice: cmp.notice, + warning: cmp.warning, + danger: cmp.danger, + }); +} + +export const completeColorVariables = completeFullColorVariables; + +function normalizeColorVariables(theme: Theme, vars: CSSVariables): CSSVariables { + const normalized: CSSVariables = {} as CSSVariables; + + for (const [key, value] of Object.entries(vars)) { + normalized[key as YaakColorKey] = value == null ? undefined : yc(theme, value).css(); + } + + return normalized; +} + +function templateTagColorVariables(theme: Theme, color: YaakColor): CSSVariables { + return completeFullColorVariables(theme, { + text: color.liftMax().lower(0.05).css(), + textSubtle: color.liftMax().lower(0.08).css(), textSubtlest: color.css(), surface: color.lower(0.2).translucify(0.8).css(), border: color.translucify(0.6).css(), borderSubtle: color.translucify(0.8).css(), surfaceHighlight: color.lower(0.1).translucify(0.7).css(), - }; + }); } -function toastColorVariables(color: YaakColor | null): Partial { - if (color == null) return {}; - - return { - text: color.lift(0.8).css(), - textSubtle: color.lift(0.8).translucify(0.3).css(), +function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables { + return completeFullColorVariables(theme, { surface: color.translucify(0.9).css(), surfaceHighlight: color.translucify(0.8).css(), - border: color.lift(0.3).translucify(0.6).css(), - }; + }); } -function bannerColorVariables(color: YaakColor | null): Partial { - if (color == null) return {}; - - return { - text: color.lift(0.8).css(), - textSubtle: color.translucify(0.3).css(), - textSubtlest: color.translucify(0.6).css(), +function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables { + return completeFullColorVariables(theme, { surface: color.translucify(0.95).css(), + surfaceHighlight: color.translucify(0.85).css(), border: color.lift(0.3).translucify(0.8).css(), - }; -} - -function _inputCSS(color: YaakColor | null): Partial { - if (color == null) return {}; - - const theme: Partial = { - border: color.css(), - }; - - return theme; + }); } function buttonSolidColorVariables( - color: YaakColor | null, + theme: Theme, + color: YaakColor, isDefault = false, -): Partial { - if (color == null) return {}; - - const theme: Partial = { - text: "white", +): CSSVariables { + const vars: Partial = { surface: color.lower(0.3).css(), surfaceHighlight: color.lower(0.1).css(), - border: color.css(), }; if (isDefault) { - theme.text = undefined; - theme.surface = undefined; - theme.surfaceHighlight = color.lift(0.08).css(); + vars.surface = undefined; + vars.surfaceHighlight = color.lift(0.08).css(); } - return theme; + return completeFullColorVariables(theme, vars); } function buttonBorderColorVariables( - color: YaakColor | null, + theme: Theme, + color: YaakColor, isDefault = false, -): Partial { - if (color == null) return {}; - +): CSSVariables { const vars: Partial = { - text: color.lift(0.8).css(), - textSubtle: color.lift(0.55).css(), - textSubtlest: color.lift(0.4).translucify(0.6).css(), + text: color.desaturate(0.4).lift(1).css(), + textSubtle: color.desaturate(0.4).lift(0.55).css(), surfaceHighlight: color.translucify(0.8).css(), borderSubtle: color.translucify(0.5).css(), border: color.translucify(0.3).css(), @@ -185,7 +208,7 @@ function buttonBorderColorVariables( vars.border = color.lift(0.5).css(); } - return vars; + return completeFullColorVariables(theme, vars); } function variablesToCSS( @@ -202,9 +225,8 @@ function variablesToCSS( return selector == null ? css : `${selector} {\n${indent(css)}\n}`; } -function componentCSS(theme: Theme, component: ComponentName): string | null { - if (theme.components == null) return null; - return variablesToCSS(`.x-theme-${component}`, themeVariables(theme, component)); +function componentCSS(component: ComponentName, vars: CSSVariables): string | null { + return variablesToCSS(`.x-theme-${component}`, vars); } function buttonCSS( @@ -216,8 +238,11 @@ function buttonCSS( if (color == null) return null; return [ - variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(color)), - variablesToCSS(`.x-theme-button--border--${colorKey}`, buttonBorderColorVariables(color)), + variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)), + variablesToCSS( + `.x-theme-button--border--${colorKey}`, + buttonBorderColorVariables(theme, color), + ), ].join("\n\n"); } @@ -229,7 +254,7 @@ function bannerCSS( const color = yc(theme, colors?.[colorKey]); if (color == null) return null; - return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(color)); + return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color)); } function toastCSS( @@ -240,7 +265,7 @@ function toastCSS( const color = yc(theme, colors?.[colorKey]); if (color == null) return null; - return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(color)); + return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color)); } function templateTagCSS( @@ -251,7 +276,10 @@ function templateTagCSS( const color = yc(theme, colors?.[colorKey]); if (color == null) return null; - return variablesToCSS(`.x-theme-templateTag--${colorKey}`, templateTagColorVariables(color)); + return variablesToCSS( + `.x-theme-templateTag--${colorKey}`, + templateTagColorVariables(theme, color), + ); } export function getThemeCSS(theme: Theme): string { @@ -264,18 +292,26 @@ export function getThemeCSS(theme: Theme): string { let themeCSS = ""; try { - const baseCss = variablesToCSS(null, themeVariables(theme)); + const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base)); + const baseSurface = yc(theme, theme.base.surface); + themeCSS = [ baseCss, - ...Object.keys(components).map((key) => componentCSS(theme, key as ComponentName)), - variablesToCSS( - ".x-theme-button--solid--default", - buttonSolidColorVariables(yc(theme, theme.base.surface), true), - ), - variablesToCSS( - ".x-theme-button--border--default", - buttonBorderColorVariables(yc(theme, theme.base.surface), true), + ...Object.entries(components).map(([key, value]) => + componentCSS(key as ComponentName, completePartialColorVariables(theme, value ?? {})), ), + baseSurface == null + ? null + : variablesToCSS( + ".x-theme-button--solid--default", + buttonSolidColorVariables(theme, baseSurface, true), + ), + baseSurface == null + ? null + : variablesToCSS( + ".x-theme-button--border--default", + buttonBorderColorVariables(theme, baseSurface, true), + ), ...Object.keys(colors).map((key) => buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors), ), @@ -360,26 +396,10 @@ function yc( export function completeTheme(theme: Theme): Theme { const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base; - const color = (value: string | null | undefined) => yc(theme, value); - theme.base.primary ??= fallback.primary; - theme.base.secondary ??= fallback.secondary; - theme.base.info ??= fallback.info; - theme.base.success ??= fallback.success; - theme.base.notice ??= fallback.notice; - theme.base.warning ??= fallback.warning; - theme.base.danger ??= fallback.danger; - - theme.base.surface ??= fallback.surface; - theme.base.surfaceHighlight ??= color(theme.base.surface)?.lift(0.06)?.css(); - theme.base.surfaceActive ??= color(theme.base.primary)?.lower(0.2).translucify(0.8).css(); - - theme.base.border ??= color(theme.base.surface)?.lift(0.12)?.css(); - theme.base.borderSubtle ??= color(theme.base.border)?.lower(0.08)?.css(); - - theme.base.text ??= fallback.text; - theme.base.textSubtle ??= color(theme.base.text)?.lower(0.3)?.css(); - theme.base.textSubtlest ??= color(theme.base.text)?.lower(0.5)?.css(); + for (const [key, value] of Object.entries(fallback)) { + theme.base[key as YaakColorKey] ??= value; + } return theme; } diff --git a/packages/theme/src/yaakColor.ts b/packages/theme/src/yaakColor.ts index e700cff9..6c9bb94e 100644 --- a/packages/theme/src/yaakColor.ts +++ b/packages/theme/src/yaakColor.ts @@ -3,9 +3,9 @@ import parseColor from "parse-color"; export class YaakColor { private readonly appearance: "dark" | "light" = "light"; - private hue = 0; - private saturation = 0; private lightness = 0; + private chroma = 0; + private hue = 0; private alpha = 1; constructor(cssColor: string, appearance: "dark" | "light" = "light") { @@ -22,11 +22,11 @@ export class YaakColor { } static white(): YaakColor { - return new YaakColor("rgb(0,0,0)", "light").lower(1); + return new YaakColor("rgb(0,0,0)", "light").lower(999); } static black(): YaakColor { - return new YaakColor("rgb(0,0,0)", "light").lift(1); + return new YaakColor("rgb(0,0,0)", "light").lift(999); } set(cssColor: string): YaakColor { @@ -35,11 +35,22 @@ export class YaakColor { const [r, g, b, a] = hexToRgba(cssColor); fixedCssColor = `rgba(${r},${g},${b},${a})`; } - const { hsla } = parseColor(fixedCssColor); - this.hue = hsla[0]; - this.saturation = hsla[1]; - this.lightness = hsla[2]; - this.alpha = hsla[3] ?? 1; + + const oklch = parseOklch(fixedCssColor); + if (oklch != null) { + this.lightness = oklch.lightness; + this.chroma = oklch.chroma; + this.hue = oklch.hue; + this.alpha = oklch.alpha; + return this; + } + + const { rgba } = parseColor(fixedCssColor); + const [lightness, chroma, hue] = rgbToOklch(rgba[0], rgba[1], rgba[2]); + this.lightness = lightness; + this.chroma = chroma; + this.hue = hue; + this.alpha = rgba[3] ?? 1; return this; } @@ -47,6 +58,10 @@ export class YaakColor { return new YaakColor(this.css(), this.appearance); } + themeColor(cssColor: string): YaakColor { + return new YaakColor(cssColor, this.appearance); + } + lower(mod: number): YaakColor { return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod); } @@ -55,6 +70,21 @@ export class YaakColor { return this.appearance === "dark" ? this._lighten(mod) : this._darken(mod); } + liftMax(): YaakColor { + return this.lift(999); + } + + lowerMax(): YaakColor { + return this.lower(999); + } + + themeSurface(): YaakColor { + return new YaakColor( + this.appearance === "dark" ? "oklch(23% 0 0)" : "oklch(100% 0 0)", + this.appearance, + ); + } + minLightness(n: number): YaakColor { const color = this.clone(); if (color.lightness < n) { @@ -69,25 +99,25 @@ export class YaakColor { translucify(mod: number): YaakColor { const color = this.clone(); - color.alpha = color.alpha - color.alpha * mod; + color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1); return color; } opacify(mod: number): YaakColor { const color = this.clone(); - color.alpha = this.alpha + (100 - this.alpha) * mod; + color.alpha = clamp(this.alpha + (1 - this.alpha) * mod, 0, 1); return color; } desaturate(mod: number): YaakColor { const color = this.clone(); - color.saturation = color.saturation - color.saturation * mod; + color.chroma = color.chroma - color.chroma * mod; return color; } saturate(mod: number): YaakColor { const color = this.clone(); - color.saturation = this.saturation + (100 - this.saturation) * mod; + color.chroma = this.chroma + this.chroma * mod; return color; } @@ -95,29 +125,233 @@ export class YaakColor { return this.lightness > color.lightness; } + contrastRatio(background: YaakColor): number { + const foreground = this.alpha < 1 ? this.compositeOver(background) : this; + const foregroundLuminance = foreground.relativeLuminance(); + const backgroundLuminance = background.relativeLuminance(); + const lighter = Math.max(foregroundLuminance, backgroundLuminance); + const darker = Math.min(foregroundLuminance, backgroundLuminance); + return (lighter + 0.05) / (darker + 0.05); + } + + withContrast(background: YaakColor, minContrast: number): YaakColor { + const darker = this.clone(); + darker.lightness = 0; + darker.chroma = 0; + darker.hue = 0; + + const lighter = this.clone(); + lighter.lightness = 100; + lighter.chroma = 0; + lighter.hue = 0; + + const darkerContrast = darker.contrastRatio(background); + const lighterContrast = lighter.contrastRatio(background); + let useLighterColor = lighterContrast >= darkerContrast; + + // Saturated accent surfaces often read better with white text even when + // black has the higher numeric contrast. Keep yellow-ish light accents dark + // by requiring white to clear a modest contrast floor first. + if (minContrast >= 3 && lighterContrast >= 2.5) { + useLighterColor = true; + } + + const selectedContrast = useLighterColor ? lighterContrast : darkerContrast; + if (selectedContrast < minContrast) { + return useLighterColor ? lighter : darker; + } + + let minLightness = 0; + let maxLightness = 100; + const color = this.clone(); + + for (let i = 0; i < 24; i += 1) { + color.lightness = (minLightness + maxLightness) / 2; + const contrast = color.contrastRatio(background); + + if (useLighterColor) { + if (contrast >= minContrast) { + maxLightness = color.lightness; + } else { + minLightness = color.lightness; + } + } else if (contrast >= minContrast) { + minLightness = color.lightness; + } else { + maxLightness = color.lightness; + } + } + + color.lightness = useLighterColor ? maxLightness : minLightness; + return color; + } + + compositeOver(background: YaakColor): YaakColor { + const [fgR, fgG, fgB] = this.rgb(); + const [bgR, bgG, bgB] = background.rgb(); + const alpha = this.alpha + background.alpha * (1 - this.alpha); + + if (alpha <= 0) { + return YaakColor.transparent(); + } + + const r = (fgR * this.alpha + bgR * background.alpha * (1 - this.alpha)) / alpha; + const g = (fgG * this.alpha + bgG * background.alpha * (1 - this.alpha)) / alpha; + const b = (fgB * this.alpha + bgB * background.alpha * (1 - this.alpha)) / alpha; + + return new YaakColor(`rgba(${r},${g},${b},${alpha})`, this.appearance); + } + css(): string { - const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb; + const [r, g, b] = this.rgb(); return rgbaToHex(r, g, b, this.alpha); } hexNoAlpha(): string { - const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb; + const [r, g, b] = this.rgb(); return rgbaToHexNoAlpha(r, g, b); } + private relativeLuminance(): number { + const [r, g, b] = this.rgb(); + const red = srgbToLinear(r / 255); + const green = srgbToLinear(g / 255); + const blue = srgbToLinear(b / 255); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; + } + + private rgb(): [number, number, number] { + return oklchToRgb(this.lightness, this.chroma, this.hue); + } + private _lighten(mod: number): YaakColor { const color = this.clone(); - color.lightness = this.lightness + (100 - this.lightness) * mod; + color.lightness = clamp(this.lightness + (100 - this.lightness) * mod, 0, 100); return color; } private _darken(mod: number): YaakColor { const color = this.clone(); - color.lightness = this.lightness - this.lightness * mod; + color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100); return color; } } +function parseOklch( + cssColor: string, +): { lightness: number; chroma: number; hue: number; alpha: number } | null { + const match = cssColor + .trim() + .match( + /^oklch\(\s*([^\s,]+)(?:\s+|,\s*)([^\s,]+)(?:\s+|,\s*)([^\s,/]+)(?:\s*\/\s*([^)]+)|(?:\s*,\s*([^)]*))?)\s*\)$/i, + ); + if (match == null) return null; + + const lightness = parseOklchLightness(match[1]); + const chroma = parseCssNumber(match[2], 1); + const hue = normalizeHue(parseCssNumber(match[3].replace(/deg$/i, ""), 1)); + const alpha = parseCssNumber(match[4] ?? match[5] ?? "1", 1); + + if ( + !Number.isFinite(lightness) || + !Number.isFinite(chroma) || + !Number.isFinite(hue) || + !Number.isFinite(alpha) + ) { + return null; + } + + return { + lightness: clamp(lightness, 0, 100), + chroma: Math.max(0, chroma), + hue, + alpha: clamp(alpha, 0, 1), + }; +} + +function parseCssNumber(value: string, percentScale: number): number { + const normalized = value.trim(); + if (normalized.endsWith("%")) { + return (Number.parseFloat(normalized) / 100) * percentScale; + } + return Number.parseFloat(normalized); +} + +function parseOklchLightness(value: string): number { + const parsed = parseCssNumber(value, 100); + return value.trim().endsWith("%") || parsed > 1 ? parsed : parsed * 100; +} + +function rgbToOklch(r: number, g: number, b: number): [number, number, number] { + const red = srgbToLinear(r / 255); + const green = srgbToLinear(g / 255); + const blue = srgbToLinear(b / 255); + + const l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue; + const m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue; + const s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue; + + const lRoot = Math.cbrt(l); + const mRoot = Math.cbrt(m); + const sRoot = Math.cbrt(s); + + const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot; + const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot; + const okb = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot; + + return [ + lightness * 100, + Math.sqrt(a * a + okb * okb), + normalizeHue(radToDeg(Math.atan2(okb, a))), + ]; +} + +function oklchToRgb(lightness: number, chroma: number, hue: number): [number, number, number] { + const l = clamp(lightness, 0, 100) / 100; + const a = Math.cos(degToRad(hue)) * chroma; + const b = Math.sin(degToRad(hue)) * chroma; + + const lRoot = l + 0.3963377774 * a + 0.2158037573 * b; + const mRoot = l - 0.1055613458 * a - 0.0638541728 * b; + const sRoot = l - 0.0894841775 * a - 1.291485548 * b; + + const lCube = lRoot * lRoot * lRoot; + const mCube = mRoot * mRoot * mRoot; + const sCube = sRoot * sRoot * sRoot; + + const red = 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube; + const green = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube; + const blue = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube; + + return [linearToSrgb(red) * 255, linearToSrgb(green) * 255, linearToSrgb(blue) * 255]; +} + +function srgbToLinear(value: number): number { + return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function linearToSrgb(value: number): number { + const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * Math.pow(value, 1 / 2.4) - 0.055; + return clamp(srgb, 0, 1); +} + +function normalizeHue(value: number): number { + const hue = value % 360; + return hue < 0 ? hue + 360 : hue; +} + +function degToRad(value: number): number { + return (value * Math.PI) / 180; +} + +function radToDeg(value: number): number { + return (value * 180) / Math.PI; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + function rgbaToHex(r: number, g: number, b: number, a: number): string { const toHex = (n: number): string => { const hex = Number(Math.round(n)).toString(16);