mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-06-21 22:19:43 +02:00
update theme generation logic
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Appearance } from "../lib/theme/appearance";
|
import type { Appearance } from "@yaakapp-internal/theme";
|
||||||
import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance";
|
import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
|
||||||
|
|
||||||
export function usePreferredAppearance() {
|
export function usePreferredAppearance() {
|
||||||
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
|
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
|
import { resolveAppearance } from "@yaakapp-internal/theme";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { resolveAppearance } from "../lib/theme/appearance";
|
|
||||||
import { usePreferredAppearance } from "./usePreferredAppearance";
|
import { usePreferredAppearance } from "./usePreferredAppearance";
|
||||||
|
|
||||||
export function useResolvedAppearance() {
|
export function useResolvedAppearance() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { getResolvedTheme, getThemes } from "../lib/theme/themes";
|
import { getResolvedTheme, getThemes } from "../lib/themes";
|
||||||
import { usePluginsKey } from "./usePlugins";
|
import { usePluginsKey } from "./usePlugins";
|
||||||
import { usePreferredAppearance } from "./usePreferredAppearance";
|
import { usePreferredAppearance } from "./usePreferredAppearance";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export type { Appearance } from "@yaakapp-internal/theme";
|
|
||||||
export {
|
|
||||||
getCSSAppearance,
|
|
||||||
getWindowAppearance,
|
|
||||||
resolveAppearance,
|
|
||||||
subscribeToPreferredAppearance,
|
|
||||||
subscribeToWindowAppearanceChange,
|
|
||||||
} from "@yaakapp-internal/theme";
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
|
|
||||||
export {
|
|
||||||
addThemeStylesToDocument,
|
|
||||||
applyThemeToDocument,
|
|
||||||
completeTheme,
|
|
||||||
getThemeCSS,
|
|
||||||
indent,
|
|
||||||
setThemeOnDocument,
|
|
||||||
} from "@yaakapp-internal/theme";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { YaakColor } from "@yaakapp-internal/theme";
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
||||||
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
import {
|
||||||
import { invokeCmd } from "../tauri";
|
defaultDarkTheme,
|
||||||
import type { Appearance } from "./appearance";
|
defaultLightTheme,
|
||||||
import { resolveAppearance } from "./appearance";
|
resolveAppearance,
|
||||||
|
type Appearance,
|
||||||
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
} from "@yaakapp-internal/theme";
|
||||||
|
import { invokeCmd } from "./tauri";
|
||||||
|
|
||||||
export async function getThemes() {
|
export async function getThemes() {
|
||||||
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
|
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
|
||||||
@@ -2,11 +2,14 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { setWindowTheme } from "@yaakapp-internal/mac-window";
|
import { setWindowTheme } from "@yaakapp-internal/mac-window";
|
||||||
import type { ModelPayload } from "@yaakapp-internal/models";
|
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 { getSettings } from "./lib/settings";
|
||||||
import type { Appearance } from "./lib/theme/appearance";
|
import { getResolvedTheme } from "./lib/themes";
|
||||||
import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance";
|
|
||||||
import { getResolvedTheme } from "./lib/theme/themes";
|
|
||||||
import { applyThemeToDocument } from "@yaakapp-internal/theme";
|
|
||||||
|
|
||||||
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
|
// 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
|
// a good appearance guess so we're not waiting too long
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export type { DocumentPlatform, YaakColorKey, YaakColors, YaakTheme } from "./wi
|
|||||||
export {
|
export {
|
||||||
addThemeStylesToDocument,
|
addThemeStylesToDocument,
|
||||||
applyThemeToDocument,
|
applyThemeToDocument,
|
||||||
|
completeColorVariables,
|
||||||
|
completeFullColorVariables,
|
||||||
|
completePartialColorVariables,
|
||||||
completeTheme,
|
completeTheme,
|
||||||
getThemeCSS,
|
getThemeCSS,
|
||||||
indent,
|
indent,
|
||||||
|
|||||||
+128
-108
@@ -47,18 +47,10 @@ export type YaakTheme = {
|
|||||||
export type YaakColorKey = keyof ThemeComponentColors;
|
export type YaakColorKey = keyof ThemeComponentColors;
|
||||||
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
|
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
|
||||||
|
|
||||||
type ComponentName = keyof NonNullable<YaakTheme["components"]>;
|
type ComponentName = keyof NonNullable<Theme["components"]>;
|
||||||
type CSSVariables = Record<YaakColorKey, string | undefined>;
|
type CSSVariables = Record<YaakColorKey, string | undefined>;
|
||||||
|
|
||||||
function themeVariables(
|
export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariables>): CSSVariables {
|
||||||
theme: Theme,
|
|
||||||
component?: ComponentName,
|
|
||||||
base?: CSSVariables,
|
|
||||||
): CSSVariables | null {
|
|
||||||
const cmp =
|
|
||||||
component == null
|
|
||||||
? theme.base
|
|
||||||
: (theme.components?.[component] ?? ({} as ThemeComponentColors));
|
|
||||||
const color = (value: string | undefined) => yc(theme, value);
|
const color = (value: string | undefined) => yc(theme, value);
|
||||||
const vars: CSSVariables = {
|
const vars: CSSVariables = {
|
||||||
surface: cmp.surface,
|
surface: cmp.surface,
|
||||||
@@ -66,12 +58,12 @@ function themeVariables(
|
|||||||
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).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(),
|
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(),
|
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
|
||||||
border: cmp.border ?? color(cmp.surface)?.lift(0.11)?.css(),
|
border: cmp.border,
|
||||||
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06)?.css(),
|
borderSubtle: cmp.borderSubtle,
|
||||||
borderFocus: color(cmp.info)?.translucify(0.5)?.css(),
|
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(),
|
||||||
text: cmp.text,
|
text: cmp.text,
|
||||||
textSubtle: cmp.textSubtle ?? color(cmp.text)?.lower(0.2)?.css(),
|
textSubtle: cmp.textSubtle,
|
||||||
textSubtlest: cmp.textSubtlest ?? color(cmp.text)?.lower(0.3)?.css(),
|
textSubtlest: cmp.textSubtlest,
|
||||||
shadow:
|
shadow:
|
||||||
cmp.shadow ??
|
cmp.shadow ??
|
||||||
YaakColor.black()
|
YaakColor.black()
|
||||||
@@ -86,95 +78,126 @@ function themeVariables(
|
|||||||
danger: cmp.danger,
|
danger: cmp.danger,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(vars)) {
|
const themeColor = (value: string) => new YaakColor(value, theme.dark ? "dark" : "light");
|
||||||
if (!value && base?.[key as YaakColorKey]) {
|
const themeSurface = themeColor(theme.dark ? "oklch(23% 0 0)" : "oklch(100% 0 0)");
|
||||||
vars[key as YaakColorKey] = base[key as YaakColorKey];
|
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<CSSVariables> {
|
export function completePartialColorVariables(
|
||||||
if (color == null) return {};
|
theme: Theme,
|
||||||
|
cmp: Partial<CSSVariables>,
|
||||||
|
): CSSVariables {
|
||||||
|
const color = (value: string | undefined) => yc(theme, value);
|
||||||
|
const text = color(cmp.text);
|
||||||
|
|
||||||
return {
|
return normalizeColorVariables(theme, {
|
||||||
text: color.lift(0.7).css(),
|
surface: cmp.surface,
|
||||||
textSubtle: color.lift(0.4).css(),
|
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(),
|
textSubtlest: color.css(),
|
||||||
surface: color.lower(0.2).translucify(0.8).css(),
|
surface: color.lower(0.2).translucify(0.8).css(),
|
||||||
border: color.translucify(0.6).css(),
|
border: color.translucify(0.6).css(),
|
||||||
borderSubtle: color.translucify(0.8).css(),
|
borderSubtle: color.translucify(0.8).css(),
|
||||||
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
|
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toastColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||||
if (color == null) return {};
|
return completeFullColorVariables(theme, {
|
||||||
|
|
||||||
return {
|
|
||||||
text: color.lift(0.8).css(),
|
|
||||||
textSubtle: color.lift(0.8).translucify(0.3).css(),
|
|
||||||
surface: color.translucify(0.9).css(),
|
surface: color.translucify(0.9).css(),
|
||||||
surfaceHighlight: color.translucify(0.8).css(),
|
surfaceHighlight: color.translucify(0.8).css(),
|
||||||
border: color.lift(0.3).translucify(0.6).css(),
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||||
if (color == null) return {};
|
return completeFullColorVariables(theme, {
|
||||||
|
|
||||||
return {
|
|
||||||
text: color.lift(0.8).css(),
|
|
||||||
textSubtle: color.translucify(0.3).css(),
|
|
||||||
textSubtlest: color.translucify(0.6).css(),
|
|
||||||
surface: color.translucify(0.95).css(),
|
surface: color.translucify(0.95).css(),
|
||||||
|
surfaceHighlight: color.translucify(0.85).css(),
|
||||||
border: color.lift(0.3).translucify(0.8).css(),
|
border: color.lift(0.3).translucify(0.8).css(),
|
||||||
};
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function _inputCSS(color: YaakColor | null): Partial<CSSVariables> {
|
|
||||||
if (color == null) return {};
|
|
||||||
|
|
||||||
const theme: Partial<ThemeComponentColors> = {
|
|
||||||
border: color.css(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return theme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buttonSolidColorVariables(
|
function buttonSolidColorVariables(
|
||||||
color: YaakColor | null,
|
theme: Theme,
|
||||||
|
color: YaakColor,
|
||||||
isDefault = false,
|
isDefault = false,
|
||||||
): Partial<CSSVariables> {
|
): CSSVariables {
|
||||||
if (color == null) return {};
|
const vars: Partial<CSSVariables> = {
|
||||||
|
|
||||||
const theme: Partial<ThemeComponentColors> = {
|
|
||||||
text: "white",
|
|
||||||
surface: color.lower(0.3).css(),
|
surface: color.lower(0.3).css(),
|
||||||
surfaceHighlight: color.lower(0.1).css(),
|
surfaceHighlight: color.lower(0.1).css(),
|
||||||
border: color.css(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
theme.text = undefined;
|
vars.surface = undefined;
|
||||||
theme.surface = undefined;
|
vars.surfaceHighlight = color.lift(0.08).css();
|
||||||
theme.surfaceHighlight = color.lift(0.08).css();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return theme;
|
return completeFullColorVariables(theme, vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buttonBorderColorVariables(
|
function buttonBorderColorVariables(
|
||||||
color: YaakColor | null,
|
theme: Theme,
|
||||||
|
color: YaakColor,
|
||||||
isDefault = false,
|
isDefault = false,
|
||||||
): Partial<CSSVariables> {
|
): CSSVariables {
|
||||||
if (color == null) return {};
|
|
||||||
|
|
||||||
const vars: Partial<CSSVariables> = {
|
const vars: Partial<CSSVariables> = {
|
||||||
text: color.lift(0.8).css(),
|
text: color.desaturate(0.4).lift(1).css(),
|
||||||
textSubtle: color.lift(0.55).css(),
|
textSubtle: color.desaturate(0.4).lift(0.55).css(),
|
||||||
textSubtlest: color.lift(0.4).translucify(0.6).css(),
|
|
||||||
surfaceHighlight: color.translucify(0.8).css(),
|
surfaceHighlight: color.translucify(0.8).css(),
|
||||||
borderSubtle: color.translucify(0.5).css(),
|
borderSubtle: color.translucify(0.5).css(),
|
||||||
border: color.translucify(0.3).css(),
|
border: color.translucify(0.3).css(),
|
||||||
@@ -185,7 +208,7 @@ function buttonBorderColorVariables(
|
|||||||
vars.border = color.lift(0.5).css();
|
vars.border = color.lift(0.5).css();
|
||||||
}
|
}
|
||||||
|
|
||||||
return vars;
|
return completeFullColorVariables(theme, vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
function variablesToCSS(
|
function variablesToCSS(
|
||||||
@@ -202,9 +225,8 @@ function variablesToCSS(
|
|||||||
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
|
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function componentCSS(theme: Theme, component: ComponentName): string | null {
|
function componentCSS(component: ComponentName, vars: CSSVariables): string | null {
|
||||||
if (theme.components == null) return null;
|
return variablesToCSS(`.x-theme-${component}`, vars);
|
||||||
return variablesToCSS(`.x-theme-${component}`, themeVariables(theme, component));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buttonCSS(
|
function buttonCSS(
|
||||||
@@ -216,8 +238,11 @@ function buttonCSS(
|
|||||||
if (color == null) return null;
|
if (color == null) return null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(color)),
|
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)),
|
||||||
variablesToCSS(`.x-theme-button--border--${colorKey}`, buttonBorderColorVariables(color)),
|
variablesToCSS(
|
||||||
|
`.x-theme-button--border--${colorKey}`,
|
||||||
|
buttonBorderColorVariables(theme, color),
|
||||||
|
),
|
||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +254,7 @@ function bannerCSS(
|
|||||||
const color = yc(theme, colors?.[colorKey]);
|
const color = yc(theme, colors?.[colorKey]);
|
||||||
if (color == null) return null;
|
if (color == null) return null;
|
||||||
|
|
||||||
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(color));
|
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toastCSS(
|
function toastCSS(
|
||||||
@@ -240,7 +265,7 @@ function toastCSS(
|
|||||||
const color = yc(theme, colors?.[colorKey]);
|
const color = yc(theme, colors?.[colorKey]);
|
||||||
if (color == null) return null;
|
if (color == null) return null;
|
||||||
|
|
||||||
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(color));
|
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color));
|
||||||
}
|
}
|
||||||
|
|
||||||
function templateTagCSS(
|
function templateTagCSS(
|
||||||
@@ -251,7 +276,10 @@ function templateTagCSS(
|
|||||||
const color = yc(theme, colors?.[colorKey]);
|
const color = yc(theme, colors?.[colorKey]);
|
||||||
if (color == null) return null;
|
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 {
|
export function getThemeCSS(theme: Theme): string {
|
||||||
@@ -264,18 +292,26 @@ export function getThemeCSS(theme: Theme): string {
|
|||||||
|
|
||||||
let themeCSS = "";
|
let themeCSS = "";
|
||||||
try {
|
try {
|
||||||
const baseCss = variablesToCSS(null, themeVariables(theme));
|
const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base));
|
||||||
|
const baseSurface = yc(theme, theme.base.surface);
|
||||||
|
|
||||||
themeCSS = [
|
themeCSS = [
|
||||||
baseCss,
|
baseCss,
|
||||||
...Object.keys(components).map((key) => componentCSS(theme, key as ComponentName)),
|
...Object.entries(components).map(([key, value]) =>
|
||||||
variablesToCSS(
|
componentCSS(key as ComponentName, completePartialColorVariables(theme, value ?? {})),
|
||||||
".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),
|
|
||||||
),
|
),
|
||||||
|
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) =>
|
...Object.keys(colors).map((key) =>
|
||||||
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
|
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
|
||||||
),
|
),
|
||||||
@@ -360,26 +396,10 @@ function yc<T extends string | null | undefined>(
|
|||||||
|
|
||||||
export function completeTheme(theme: Theme): Theme {
|
export function completeTheme(theme: Theme): Theme {
|
||||||
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
|
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
|
||||||
const color = (value: string | null | undefined) => yc(theme, value);
|
|
||||||
|
|
||||||
theme.base.primary ??= fallback.primary;
|
for (const [key, value] of Object.entries(fallback)) {
|
||||||
theme.base.secondary ??= fallback.secondary;
|
theme.base[key as YaakColorKey] ??= value;
|
||||||
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();
|
|
||||||
|
|
||||||
return theme;
|
return theme;
|
||||||
}
|
}
|
||||||
|
|||||||
+251
-17
@@ -3,9 +3,9 @@ import parseColor from "parse-color";
|
|||||||
export class YaakColor {
|
export class YaakColor {
|
||||||
private readonly appearance: "dark" | "light" = "light";
|
private readonly appearance: "dark" | "light" = "light";
|
||||||
|
|
||||||
private hue = 0;
|
|
||||||
private saturation = 0;
|
|
||||||
private lightness = 0;
|
private lightness = 0;
|
||||||
|
private chroma = 0;
|
||||||
|
private hue = 0;
|
||||||
private alpha = 1;
|
private alpha = 1;
|
||||||
|
|
||||||
constructor(cssColor: string, appearance: "dark" | "light" = "light") {
|
constructor(cssColor: string, appearance: "dark" | "light" = "light") {
|
||||||
@@ -22,11 +22,11 @@ export class YaakColor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static white(): 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 {
|
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 {
|
set(cssColor: string): YaakColor {
|
||||||
@@ -35,11 +35,22 @@ export class YaakColor {
|
|||||||
const [r, g, b, a] = hexToRgba(cssColor);
|
const [r, g, b, a] = hexToRgba(cssColor);
|
||||||
fixedCssColor = `rgba(${r},${g},${b},${a})`;
|
fixedCssColor = `rgba(${r},${g},${b},${a})`;
|
||||||
}
|
}
|
||||||
const { hsla } = parseColor(fixedCssColor);
|
|
||||||
this.hue = hsla[0];
|
const oklch = parseOklch(fixedCssColor);
|
||||||
this.saturation = hsla[1];
|
if (oklch != null) {
|
||||||
this.lightness = hsla[2];
|
this.lightness = oklch.lightness;
|
||||||
this.alpha = hsla[3] ?? 1;
|
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;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +58,10 @@ export class YaakColor {
|
|||||||
return new YaakColor(this.css(), this.appearance);
|
return new YaakColor(this.css(), this.appearance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
themeColor(cssColor: string): YaakColor {
|
||||||
|
return new YaakColor(cssColor, this.appearance);
|
||||||
|
}
|
||||||
|
|
||||||
lower(mod: number): YaakColor {
|
lower(mod: number): YaakColor {
|
||||||
return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod);
|
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);
|
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 {
|
minLightness(n: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
if (color.lightness < n) {
|
if (color.lightness < n) {
|
||||||
@@ -69,25 +99,25 @@ export class YaakColor {
|
|||||||
|
|
||||||
translucify(mod: number): YaakColor {
|
translucify(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.alpha = color.alpha - color.alpha * mod;
|
color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1);
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
opacify(mod: number): YaakColor {
|
opacify(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
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;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
desaturate(mod: number): YaakColor {
|
desaturate(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.saturation = color.saturation - color.saturation * mod;
|
color.chroma = color.chroma - color.chroma * mod;
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
saturate(mod: number): YaakColor {
|
saturate(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.saturation = this.saturation + (100 - this.saturation) * mod;
|
color.chroma = this.chroma + this.chroma * mod;
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,29 +125,233 @@ export class YaakColor {
|
|||||||
return this.lightness > color.lightness;
|
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 {
|
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);
|
return rgbaToHex(r, g, b, this.alpha);
|
||||||
}
|
}
|
||||||
|
|
||||||
hexNoAlpha(): string {
|
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);
|
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 {
|
private _lighten(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
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;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _darken(mod: number): YaakColor {
|
private _darken(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.lightness = this.lightness - this.lightness * mod;
|
color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100);
|
||||||
return color;
|
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 {
|
function rgbaToHex(r: number, g: number, b: number, a: number): string {
|
||||||
const toHex = (n: number): string => {
|
const toHex = (n: number): string => {
|
||||||
const hex = Number(Math.round(n)).toString(16);
|
const hex = Number(Math.round(n)).toString(16);
|
||||||
|
|||||||
Reference in New Issue
Block a user