mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-15 05:33:29 +01:00

360 lines
10 KiB
TypeScript
360 lines
10 KiB
TypeScript
import { defaultDarkTheme, defaultLightTheme } from './themes';
|
|
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 = YaakColors & {
|
|
id: string;
|
|
name: string;
|
|
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 YaakColors;
|
|
|
|
type ComponentName = keyof NonNullable<YaakTheme['components']>;
|
|
|
|
type CSSVariables = Record<YaakColorKey, YaakColor | undefined>;
|
|
|
|
function themeVariables(theme?: Partial<YaakColors>, base?: CSSVariables): CSSVariables | null {
|
|
const vars: CSSVariables = {
|
|
surface: theme?.surface,
|
|
surfaceHighlight: theme?.surfaceHighlight ?? theme?.surface?.lift(0.06),
|
|
surfaceActive: theme?.surfaceActive ?? theme?.primary?.lower(0.2).translucify(0.8),
|
|
backdrop: theme?.surface?.lower(0.2).translucify(0.2),
|
|
selection: theme?.primary?.lower(0.1).translucify(0.7),
|
|
border: theme?.border ?? theme?.surface?.lift(0.11),
|
|
borderSubtle: theme?.borderSubtle ?? theme?.border?.lower(0.06),
|
|
borderFocus: theme?.info?.translucify(0.5),
|
|
text: theme?.text,
|
|
textSubtle: theme?.textSubtle ?? theme?.text?.lower(0.2),
|
|
textSubtlest: theme?.textSubtlest ?? theme?.text?.lower(0.3),
|
|
shadow:
|
|
theme?.shadow ??
|
|
YaakColor.black().translucify(isThemeDark(theme ?? ({} as Partial<YaakColors>)) ? 0.7 : 0.93),
|
|
primary: theme?.primary,
|
|
secondary: theme?.primary,
|
|
info: theme?.info,
|
|
success: theme?.success,
|
|
notice: theme?.notice,
|
|
warning: theme?.warning,
|
|
danger: theme?.danger,
|
|
};
|
|
|
|
// Extend with base
|
|
for (const [k, v] of Object.entries(vars)) {
|
|
if (!v && base?.[k as YaakColorKey]) {
|
|
vars[k as YaakColorKey] = base[k as YaakColorKey];
|
|
}
|
|
}
|
|
|
|
return vars;
|
|
}
|
|
|
|
function templateTagColorVariables(color: YaakColor): Partial<CSSVariables> {
|
|
return {
|
|
text: color.lift(0.6),
|
|
textSubtle: color.lift(0.4),
|
|
textSubtlest: color,
|
|
surface: color.lower(0.2).translucify(0.8),
|
|
border: color.lower(0.2).translucify(0.2),
|
|
surfaceHighlight: color.lower(0.1).translucify(0.7),
|
|
};
|
|
}
|
|
|
|
function bannerColorVariables(color: YaakColor): Partial<CSSVariables> {
|
|
return {
|
|
text: color.lift(0.8),
|
|
textSubtle: color.translucify(0.3),
|
|
textSubtlest: color,
|
|
surface: color,
|
|
border: color.lift(0.3).translucify(0.4),
|
|
surfaceHighlight: color.translucify(0.9),
|
|
};
|
|
}
|
|
|
|
function buttonSolidColorVariables(
|
|
color: YaakColor,
|
|
isDefault: boolean = false,
|
|
): Partial<CSSVariables> {
|
|
const theme: Partial<YaakTheme> = {
|
|
text: new YaakColor('white', 'dark'),
|
|
surface: color.lower(0.3),
|
|
surfaceHighlight: color.lower(0.1),
|
|
border: color,
|
|
};
|
|
|
|
if (isDefault) {
|
|
theme.text = color.lift(0.8);
|
|
theme.surface = undefined; // Inherit from root
|
|
theme.surfaceHighlight = color.lift(0.08);
|
|
}
|
|
|
|
return theme;
|
|
}
|
|
|
|
function buttonBorderColorVariables(
|
|
color: YaakColor,
|
|
isDefault: boolean = false,
|
|
): Partial<CSSVariables> {
|
|
const theme = {
|
|
text: color.lift(0.8),
|
|
textSubtle: color.lift(0.55),
|
|
textSubtlest: color.lift(0.4).translucify(0.6),
|
|
surfaceHighlight: color.translucify(0.8),
|
|
borderSubtle: color.translucify(0.5),
|
|
border: color.translucify(0.3),
|
|
};
|
|
|
|
if (isDefault) {
|
|
theme.borderSubtle = color.lift(0.28);
|
|
theme.border = color.lift(0.5);
|
|
}
|
|
|
|
return theme;
|
|
}
|
|
|
|
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?.css()};`)
|
|
.join('\n');
|
|
|
|
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
|
|
}
|
|
|
|
function componentCSS(
|
|
component: ComponentName,
|
|
components?: YaakTheme['components'],
|
|
): string | null {
|
|
if (components == null) {
|
|
return null;
|
|
}
|
|
|
|
const themeVars = themeVariables(components[component]);
|
|
return variablesToCSS(`.x-theme-${component}`, themeVars);
|
|
}
|
|
|
|
function buttonCSS(color: YaakColorKey, colors?: Partial<YaakColors>): string | null {
|
|
const yaakColor = colors?.[color];
|
|
if (yaakColor == null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
variablesToCSS(`.x-theme-button--solid--${color}`, buttonSolidColorVariables(yaakColor)),
|
|
variablesToCSS(`.x-theme-button--border--${color}`, buttonBorderColorVariables(yaakColor)),
|
|
].join('\n\n');
|
|
}
|
|
|
|
function bannerCSS(color: YaakColorKey, colors?: Partial<YaakColors>): string | null {
|
|
const yaakColor = colors?.[color];
|
|
if (yaakColor == null) {
|
|
return null;
|
|
}
|
|
|
|
return [variablesToCSS(`.x-theme-banner--${color}`, bannerColorVariables(yaakColor))].join(
|
|
'\n\n',
|
|
);
|
|
}
|
|
|
|
function templateTagCSS(color: YaakColorKey, colors?: Partial<YaakColors>): string | null {
|
|
const yaakColor = colors?.[color];
|
|
if (yaakColor == null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
variablesToCSS(`.x-theme-templateTag--${color}`, templateTagColorVariables(yaakColor)),
|
|
].join('\n\n');
|
|
}
|
|
|
|
export function isThemeDark(theme: Partial<YaakColors>): boolean {
|
|
if (theme.surface && theme.text) {
|
|
return theme.text.lighterThan(theme.surface);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function getThemeCSS(theme: YaakTheme): string {
|
|
theme.components = theme.components ?? {};
|
|
// Toast defaults to menu styles
|
|
theme.components.toast = theme.components.toast ?? theme.components.menu ?? {};
|
|
const { components, id, name } = theme;
|
|
const colors = Object.keys(theme)
|
|
.filter((key) => theme[key as YaakColorKey] instanceof YaakColor)
|
|
.reduce((prev, key) => {
|
|
return { ...prev, [key]: theme[key as YaakColorKey] };
|
|
}, {});
|
|
|
|
let themeCSS = '';
|
|
try {
|
|
const baseCss = variablesToCSS(null, themeVariables(theme));
|
|
themeCSS = [
|
|
baseCss,
|
|
...Object.keys(components ?? {}).map((key) =>
|
|
componentCSS(key as ComponentName, theme.components),
|
|
),
|
|
variablesToCSS(
|
|
`.x-theme-button--solid--default`,
|
|
buttonSolidColorVariables(theme.surface, true),
|
|
),
|
|
variablesToCSS(
|
|
`.x-theme-button--border--default`,
|
|
buttonBorderColorVariables(theme.surface, true),
|
|
),
|
|
...Object.keys(colors ?? {}).map((key) =>
|
|
buttonCSS(key as YaakColorKey, theme.components?.button ?? colors),
|
|
),
|
|
...Object.keys(colors ?? {}).map((key) =>
|
|
bannerCSS(key as YaakColorKey, theme.components?.banner ?? colors),
|
|
),
|
|
...Object.keys(colors ?? {}).map((key) =>
|
|
templateTagCSS(key as YaakColorKey, theme.components?.templateTag ?? colors),
|
|
),
|
|
].join('\n\n');
|
|
} catch (err) {
|
|
console.error('Failed to generate CSS', err);
|
|
}
|
|
|
|
return [`/* ${name} */`, `[data-theme="${id}"] {`, indent(themeCSS), '}'].join('\n');
|
|
}
|
|
|
|
export function addThemeStylesToDocument(theme: YaakTheme) {
|
|
theme = completeTheme(theme);
|
|
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: YaakTheme) {
|
|
document.documentElement.setAttribute('data-theme', theme.id);
|
|
}
|
|
|
|
export function indent(text: string, space = ' '): string {
|
|
return text
|
|
.split('\n')
|
|
.map((line) => space + line)
|
|
.join('\n');
|
|
}
|
|
|
|
export function completeTheme(theme: YaakTheme): YaakTheme {
|
|
const isDark = isThemeDark(theme);
|
|
|
|
// Clone the theme
|
|
theme = deserializeTheme(serializeTheme(theme), isDark ? 'dark' : 'light');
|
|
|
|
const base = isDark ? defaultDarkTheme : defaultLightTheme;
|
|
|
|
theme.primary = theme.primary ?? base.primary;
|
|
theme.secondary = theme.secondary ?? base.secondary;
|
|
theme.info = theme.info ?? base.info;
|
|
theme.success = theme.success ?? base.success;
|
|
theme.notice = theme.notice ?? base.notice;
|
|
theme.warning = theme.warning ?? base.warning;
|
|
theme.danger = theme.danger ?? base.danger;
|
|
|
|
theme.surface = theme.surface ?? base.surface;
|
|
theme.surfaceHighlight = theme.surfaceHighlight ?? theme.surface?.lift(0.06);
|
|
theme.surfaceActive = theme.surfaceActive ?? theme.primary?.lower(0.2).translucify(0.8);
|
|
|
|
theme.border = theme.border ?? theme.surface?.lift(0.12);
|
|
theme.borderSubtle = theme.borderSubtle ?? theme.border?.lower(0.08);
|
|
|
|
theme.text = theme.text ?? theme.border?.lift(1).lower(0.2);
|
|
theme.textSubtle = theme.textSubtle ?? theme.text?.lower(0.3);
|
|
theme.textSubtlest = theme.textSubtlest ?? theme.text?.lower(0.5);
|
|
|
|
return theme;
|
|
}
|
|
|
|
export function serializeTheme(theme: YaakTheme): string {
|
|
function next(o: Record<string, unknown>) {
|
|
o = { ...o }; // Clone first
|
|
|
|
for (const k of Object.keys(o)) {
|
|
const v = o[k];
|
|
if (v instanceof YaakColor) {
|
|
o[k] = v.css();
|
|
} else if (Object.prototype.toString.call(v) === '[object Object]') {
|
|
o[k] = next(v as Record<string, unknown>);
|
|
} else {
|
|
o[k] = v;
|
|
}
|
|
}
|
|
return o;
|
|
}
|
|
|
|
return JSON.stringify(next(theme));
|
|
}
|
|
|
|
export function deserializeTheme(theme: string, appearance: 'dark' | 'light'): YaakTheme {
|
|
function next(o: Record<string, unknown>) {
|
|
for (const k of Object.keys(o)) {
|
|
const v = o[k];
|
|
if (v instanceof YaakColor) {
|
|
o[k] = v;
|
|
} else if (typeof v === 'string' && v.match(/^(#|hsla\()/)) {
|
|
o[k] = new YaakColor(v, appearance);
|
|
} else if (Object.prototype.toString.call(v) === '[object Object]') {
|
|
o[k] = next(v as Record<string, unknown>);
|
|
} else {
|
|
o[k] = v;
|
|
}
|
|
}
|
|
return o;
|
|
}
|
|
|
|
return next(JSON.parse(theme)) as YaakTheme;
|
|
}
|