mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-21 19:04:05 +02:00
Tag text was black in light themes and correctly tinted in dark. Both came from liftMax(), which pushes lightness to an extreme, and the two extremes are not symmetric: lightening past the sRGB gamut clips to a still-tinted color, so dark kept its hue by accident, while darkening to zero lightness collapses every hue to black. Stopping short of the extreme keeps the hue in both appearances with no branch on appearance, and still clears 6:1 against the tag's own surface across every accent. Dark shifts from washed out toward the accent, so a purple tag now reads purple rather than near-white pink.
414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import type { Theme, ThemeComponentColors } from "@yaakapp-internal/plugins";
|
|
import { defaultDarkTheme, defaultLightTheme } from "./defaultThemes";
|
|
import { YaakColor } from "./yaakColor";
|
|
|
|
export type YaakColors = {
|
|
surface: YaakColor;
|
|
surfaceHighlight?: YaakColor;
|
|
surfaceActive?: YaakColor;
|
|
text: YaakColor;
|
|
textSubtle?: YaakColor;
|
|
textSubtlest?: YaakColor;
|
|
border?: YaakColor;
|
|
borderSubtle?: YaakColor;
|
|
borderFocus?: YaakColor;
|
|
shadow?: YaakColor;
|
|
backdrop?: YaakColor;
|
|
selection?: YaakColor;
|
|
primary?: YaakColor;
|
|
secondary?: YaakColor;
|
|
info?: YaakColor;
|
|
success?: YaakColor;
|
|
notice?: YaakColor;
|
|
warning?: YaakColor;
|
|
danger?: YaakColor;
|
|
};
|
|
|
|
export type YaakTheme = {
|
|
id: string;
|
|
name: string;
|
|
base: YaakColors;
|
|
components?: Partial<{
|
|
dialog: Partial<YaakColors>;
|
|
menu: Partial<YaakColors>;
|
|
toast: Partial<YaakColors>;
|
|
sidebar: Partial<YaakColors>;
|
|
responsePane: Partial<YaakColors>;
|
|
appHeader: Partial<YaakColors>;
|
|
button: Partial<YaakColors>;
|
|
banner: Partial<YaakColors>;
|
|
templateTag: Partial<YaakColors>;
|
|
urlBar: Partial<YaakColors>;
|
|
editor: Partial<YaakColors>;
|
|
input: Partial<YaakColors>;
|
|
}>;
|
|
};
|
|
|
|
export type YaakColorKey = keyof ThemeComponentColors;
|
|
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
|
|
|
|
type ComponentName = keyof NonNullable<Theme["components"]>;
|
|
type CSSVariables = Record<YaakColorKey, string | undefined>;
|
|
|
|
export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariables>): CSSVariables {
|
|
const color = (value: string | undefined) => yc(theme, value);
|
|
const vars: CSSVariables = {
|
|
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,
|
|
borderSubtle: cmp.borderSubtle,
|
|
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(),
|
|
text: cmp.text,
|
|
textSubtle: cmp.textSubtle,
|
|
textSubtlest: cmp.textSubtlest,
|
|
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,
|
|
};
|
|
|
|
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 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(),
|
|
});
|
|
}
|
|
|
|
export function completePartialColorVariables(
|
|
theme: Theme,
|
|
cmp: Partial<CSSVariables>,
|
|
): CSSVariables {
|
|
const color = (value: string | undefined) => yc(theme, value);
|
|
const text = color(cmp.text);
|
|
|
|
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, {
|
|
// Lift toward the foreground, but not all the way. liftMax() pushes lightness to an
|
|
// extreme, and the two extremes don't behave alike: lightening past the sRGB gamut clips
|
|
// to a still-tinted color, while darkening to zero lightness collapses every hue to black.
|
|
// That's why tags kept their color in dark mode and turned black in light. Stopping short
|
|
// keeps the hue in both, and still clears 6:1 against the tag's own surface.
|
|
text: color.lift(0.6).css(),
|
|
textSubtle: color.lift(0.5).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(theme: Theme, color: YaakColor): CSSVariables {
|
|
return completeFullColorVariables(theme, {
|
|
surface: color.translucify(0.9).css(),
|
|
surfaceHighlight: color.translucify(0.8).css(),
|
|
});
|
|
}
|
|
|
|
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
|
return completeFullColorVariables(theme, {
|
|
text: color.desaturate(0.5).lift(0.12).css(),
|
|
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
|
|
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
|
|
surface: color.translucify(0.95).css(),
|
|
surfaceHighlight: color.translucify(0.85).css(),
|
|
border: color.lift(0.3).translucify(0.8).css(),
|
|
});
|
|
}
|
|
|
|
function buttonSolidColorVariables(
|
|
theme: Theme,
|
|
color: YaakColor,
|
|
isDefault = false,
|
|
): CSSVariables {
|
|
const vars: Partial<CSSVariables> = {
|
|
surface: color.lower(0.3).css(),
|
|
surfaceHighlight: color.lower(0.1).css(),
|
|
};
|
|
|
|
if (isDefault) {
|
|
vars.surface = undefined;
|
|
vars.surfaceHighlight = color.lift(0.08).css();
|
|
}
|
|
|
|
return completeFullColorVariables(theme, vars);
|
|
}
|
|
|
|
function buttonBorderColorVariables(
|
|
theme: Theme,
|
|
color: YaakColor,
|
|
isDefault = false,
|
|
): CSSVariables {
|
|
const vars: Partial<CSSVariables> = {
|
|
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(),
|
|
};
|
|
|
|
if (isDefault) {
|
|
vars.borderSubtle = color.lift(0.28).css();
|
|
vars.border = color.lift(0.5).css();
|
|
}
|
|
|
|
return completeFullColorVariables(theme, vars);
|
|
}
|
|
|
|
function variablesToCSS(
|
|
selector: string | null,
|
|
vars: Partial<CSSVariables> | null,
|
|
): string | null {
|
|
if (vars == null) return null;
|
|
|
|
const css = Object.entries(vars)
|
|
.filter(([, value]) => value)
|
|
.map(([name, value]) => `--${name}: ${value};`)
|
|
.join("\n");
|
|
|
|
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
|
|
}
|
|
|
|
function componentCSS(component: ComponentName, vars: CSSVariables): string | null {
|
|
return variablesToCSS(`.x-theme-${component}`, vars);
|
|
}
|
|
|
|
function buttonCSS(
|
|
theme: Theme,
|
|
colorKey: YaakColorKey,
|
|
colors?: ThemeComponentColors,
|
|
): string | null {
|
|
const color = yc(theme, colors?.[colorKey]);
|
|
if (color == null) return null;
|
|
|
|
return [
|
|
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)),
|
|
variablesToCSS(
|
|
`.x-theme-button--border--${colorKey}`,
|
|
buttonBorderColorVariables(theme, color),
|
|
),
|
|
].join("\n\n");
|
|
}
|
|
|
|
function bannerCSS(
|
|
theme: Theme,
|
|
colorKey: YaakColorKey,
|
|
colors?: ThemeComponentColors,
|
|
): string | null {
|
|
const color = yc(theme, colors?.[colorKey]);
|
|
if (color == null) return null;
|
|
|
|
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color));
|
|
}
|
|
|
|
function toastCSS(
|
|
theme: Theme,
|
|
colorKey: YaakColorKey,
|
|
colors?: ThemeComponentColors,
|
|
): string | null {
|
|
const color = yc(theme, colors?.[colorKey]);
|
|
if (color == null) return null;
|
|
|
|
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color));
|
|
}
|
|
|
|
function templateTagCSS(
|
|
theme: Theme,
|
|
colorKey: YaakColorKey,
|
|
colors?: ThemeComponentColors,
|
|
): string | null {
|
|
const color = yc(theme, colors?.[colorKey]);
|
|
if (color == null) return null;
|
|
|
|
return variablesToCSS(
|
|
`.x-theme-templateTag--${colorKey}`,
|
|
templateTagColorVariables(theme, color),
|
|
);
|
|
}
|
|
|
|
export function getThemeCSS(theme: Theme): string {
|
|
theme.components = theme.components ?? {};
|
|
theme.components.toast = theme.components.toast ?? theme.components.menu ?? {};
|
|
const { components, id, label } = theme;
|
|
const colors = Object.keys(theme.base).reduce((prev, key) => {
|
|
return { ...prev, [key]: theme.base[key as YaakColorKey] };
|
|
}, {} as ThemeComponentColors);
|
|
|
|
let themeCSS = "";
|
|
try {
|
|
const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base));
|
|
const baseSurface = yc(theme, theme.base.surface);
|
|
|
|
themeCSS = [
|
|
baseCss,
|
|
...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),
|
|
),
|
|
...Object.keys(colors).map((key) =>
|
|
bannerCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),
|
|
),
|
|
...Object.keys(colors).map((key) =>
|
|
toastCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),
|
|
),
|
|
...Object.keys(colors).map((key) =>
|
|
templateTagCSS(theme, key as YaakColorKey, theme.components?.templateTag ?? colors),
|
|
),
|
|
].join("\n\n");
|
|
} catch (err) {
|
|
console.error("Failed to generate CSS", err);
|
|
}
|
|
|
|
return [`/* ${label} */`, `[data-theme="${id}"] {`, indent(themeCSS), "}"].join("\n");
|
|
}
|
|
|
|
export function addThemeStylesToDocument(rawTheme: Theme | null) {
|
|
if (rawTheme == null) {
|
|
console.error("Failed to add theme styles: theme is null");
|
|
return;
|
|
}
|
|
|
|
const theme = completeTheme(rawTheme);
|
|
let styleEl = document.head.querySelector("style[data-theme]");
|
|
if (!styleEl) {
|
|
styleEl = document.createElement("style");
|
|
document.head.appendChild(styleEl);
|
|
}
|
|
|
|
styleEl.setAttribute("data-theme", theme.id);
|
|
styleEl.setAttribute("data-updated-at", new Date().toISOString());
|
|
styleEl.textContent = getThemeCSS(theme);
|
|
}
|
|
|
|
export function setThemeOnDocument(theme: Theme | null) {
|
|
if (theme == null) {
|
|
console.error("Failed to set theme: theme is null");
|
|
return;
|
|
}
|
|
|
|
document.documentElement.setAttribute("data-theme", theme.id);
|
|
}
|
|
|
|
export function applyThemeToDocument(theme: Theme | null) {
|
|
addThemeStylesToDocument(theme);
|
|
setThemeOnDocument(theme);
|
|
}
|
|
|
|
export function platformFromUserAgent(userAgent: string): DocumentPlatform {
|
|
const normalized = userAgent.toLowerCase();
|
|
|
|
if (normalized.includes("linux")) return "linux";
|
|
if (normalized.includes("mac os") || normalized.includes("macintosh")) return "macos";
|
|
if (normalized.includes("win")) return "windows";
|
|
return "unknown";
|
|
}
|
|
|
|
export function setPlatformOnDocument(platform: string | null | undefined) {
|
|
const normalized =
|
|
platform === "linux" || platform === "macos" || platform === "windows" ? platform : "unknown";
|
|
document.documentElement.setAttribute("data-platform", normalized);
|
|
}
|
|
|
|
export function indent(text: string, space = " "): string {
|
|
return text
|
|
.split("\n")
|
|
.map((line) => space + line)
|
|
.join("\n");
|
|
}
|
|
|
|
function yc<T extends string | null | undefined>(
|
|
theme: Theme,
|
|
value: T,
|
|
): T extends string ? YaakColor : null {
|
|
if (value == null) return null as never;
|
|
return new YaakColor(value, theme.dark ? "dark" : "light") as never;
|
|
}
|
|
|
|
export function completeTheme(theme: Theme): Theme {
|
|
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
|
|
|
|
for (const [key, value] of Object.entries(fallback)) {
|
|
theme.base[key as YaakColorKey] ??= value;
|
|
}
|
|
|
|
return theme;
|
|
}
|