mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-15 16:23:25 +01:00
245 lines
7.2 KiB
TypeScript
245 lines
7.2 KiB
TypeScript
import { Color } from './color';
|
|
|
|
export type Appearance = 'dark' | 'light' | 'system';
|
|
|
|
interface ThemeComponent {
|
|
background?: Color;
|
|
backgroundHighlight?: Color;
|
|
backgroundHighlightSecondary?: Color;
|
|
backgroundActive?: Color;
|
|
foreground?: Color;
|
|
foregroundSubtle?: Color;
|
|
foregroundSubtler?: Color;
|
|
shadow?: Color;
|
|
colors?: Partial<RootColors>;
|
|
}
|
|
|
|
export interface YaakTheme extends ThemeComponent {
|
|
id: string;
|
|
name: string;
|
|
components?: {
|
|
dialog?: ThemeComponent;
|
|
menu?: ThemeComponent;
|
|
toast?: ThemeComponent;
|
|
sidebar?: ThemeComponent;
|
|
responsePane?: ThemeComponent;
|
|
appHeader?: ThemeComponent;
|
|
button?: ThemeComponent;
|
|
banner?: ThemeComponent;
|
|
placeholder?: ThemeComponent;
|
|
urlBar?: ThemeComponent;
|
|
editor?: ThemeComponent;
|
|
input?: ThemeComponent;
|
|
};
|
|
}
|
|
|
|
interface RootColors {
|
|
primary: Color;
|
|
secondary: Color;
|
|
info: Color;
|
|
success: Color;
|
|
notice: Color;
|
|
warning: Color;
|
|
danger: Color;
|
|
}
|
|
|
|
type ColorName = keyof RootColors;
|
|
type ComponentName = keyof NonNullable<YaakTheme['components']>;
|
|
|
|
type CSSVariables = Record<string, string | undefined>;
|
|
|
|
function themeVariables(theme?: ThemeComponent, base?: CSSVariables): CSSVariables | null {
|
|
const vars: CSSVariables = {
|
|
'--background': theme?.background?.css(),
|
|
'--background-highlight':
|
|
theme?.backgroundHighlight?.css() ?? theme?.background?.lift(0.11).css(),
|
|
'--background-highlight-secondary':
|
|
theme?.backgroundHighlightSecondary?.css() ?? theme?.background?.lift(0.06).css(),
|
|
'--background-active':
|
|
theme?.backgroundActive?.css() ?? theme?.colors?.primary?.lower(0.2).translucify(0.8).css(),
|
|
'--background-backdrop': theme?.background?.lower(0.2).translucify(0.2).css(),
|
|
'--background-selection': theme?.colors?.primary?.lower(0.1).translucify(0.7).css(),
|
|
'--fg': theme?.foreground?.css(),
|
|
'--fg-subtle': theme?.foregroundSubtle?.css() ?? theme?.foreground?.lower(0.2).css(),
|
|
'--fg-subtler': theme?.foregroundSubtler?.css() ?? theme?.foreground?.lower(0.3).css(),
|
|
'--border-focus': theme?.colors?.info?.css(),
|
|
'--shadow':
|
|
theme?.shadow?.css() ??
|
|
Color.black()
|
|
.translucify(isThemeDark(theme ?? {}) ? 0.7 : 0.93)
|
|
.css(),
|
|
};
|
|
|
|
for (const [color, value] of Object.entries(theme?.colors ?? {})) {
|
|
vars[`--fg-${color}`] = (value as Color).css();
|
|
}
|
|
|
|
// Extend with base
|
|
for (const [k, v] of Object.entries(vars)) {
|
|
if (!v && base?.[k]) {
|
|
vars[k] = base[k];
|
|
}
|
|
}
|
|
|
|
return vars;
|
|
}
|
|
|
|
function placeholderColorVariables(color: Color): CSSVariables {
|
|
return {
|
|
'--fg': color.lift(0.6).css(),
|
|
'--fg-subtle': color.lift(0.4).css(),
|
|
'--fg-subtler': color.css(),
|
|
'--background': color.lower(0.2).translucify(0.8).css(),
|
|
'--background-highlight': color.lower(0.2).translucify(0.2).css(),
|
|
'--background-highlight-secondary': color.lower(0.1).translucify(0.7).css(),
|
|
};
|
|
}
|
|
|
|
function bannerColorVariables(color: Color): CSSVariables {
|
|
return {
|
|
'--fg': color.lift(0.8).css(),
|
|
'--fg-subtle': color.translucify(0.3).css(),
|
|
'--fg-subtler': color.css(),
|
|
'--background': color.css(),
|
|
'--background-highlight': color.lift(0.3).translucify(0.4).css(),
|
|
'--background-highlight-secondary': color.translucify(0.9).css(),
|
|
};
|
|
}
|
|
|
|
function buttonSolidColorVariables(color: Color): CSSVariables {
|
|
return {
|
|
'--fg': new Color('white', 'dark').css(),
|
|
'--background': color.lower(0.15).css(),
|
|
'--background-highlight': color.css(),
|
|
'--background-highlight-secondary': color.lower(0.3).css(),
|
|
};
|
|
}
|
|
|
|
function buttonBorderColorVariables(color: Color): CSSVariables {
|
|
return {
|
|
'--fg': color.lift(0.6).css(),
|
|
'--fg-subtle': color.lift(0.4).css(),
|
|
'--fg-subtler': color.lift(0.4).translucify(0.6).css(),
|
|
'--background': Color.transparent().css(),
|
|
'--background-highlight': color.translucify(0.8).css(),
|
|
};
|
|
}
|
|
|
|
function variablesToCSS(selector: string | null, vars: 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,
|
|
components?: YaakTheme['components'],
|
|
): string | null {
|
|
if (components == null) {
|
|
return null;
|
|
}
|
|
|
|
const themeVars = themeVariables(components[component]);
|
|
return variablesToCSS(`.x-theme-${component}`, themeVars);
|
|
}
|
|
|
|
function buttonCSS(color: ColorName, colors?: Partial<RootColors>): string | null {
|
|
const cssColor = colors?.[color];
|
|
if (cssColor == null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
variablesToCSS(`.x-theme-button--solid--${color}`, buttonSolidColorVariables(cssColor)),
|
|
variablesToCSS(`.x-theme-button--border--${color}`, buttonBorderColorVariables(cssColor)),
|
|
].join('\n\n');
|
|
}
|
|
|
|
function bannerCSS(color: ColorName, colors?: Partial<RootColors>): string | null {
|
|
const cssColor = colors?.[color];
|
|
if (cssColor == null) {
|
|
return null;
|
|
}
|
|
|
|
return [variablesToCSS(`.x-theme-banner--${color}`, bannerColorVariables(cssColor))].join('\n\n');
|
|
}
|
|
|
|
function placeholderCSS(color: ColorName, colors?: Partial<RootColors>): string | null {
|
|
const cssColor = colors?.[color];
|
|
if (cssColor == null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
variablesToCSS(`.x-theme-placeholder--${color}`, placeholderColorVariables(cssColor)),
|
|
].join('\n\n');
|
|
}
|
|
|
|
export function isThemeDark(theme: ThemeComponent): boolean {
|
|
if (theme.background && theme.foreground) {
|
|
return theme.foreground.lighterThan(theme.background);
|
|
}
|
|
|
|
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;
|
|
|
|
let themeCSS = '';
|
|
try {
|
|
const baseCss = variablesToCSS(null, themeVariables(theme));
|
|
const { components, colors } = theme;
|
|
themeCSS = [
|
|
baseCss,
|
|
...Object.keys(components ?? {}).map((key) =>
|
|
componentCSS(key as ComponentName, theme.components),
|
|
),
|
|
...Object.keys(colors ?? {}).map((key) =>
|
|
buttonCSS(key as ColorName, theme.components?.button?.colors ?? colors),
|
|
),
|
|
...Object.keys(colors ?? {}).map((key) =>
|
|
bannerCSS(key as ColorName, theme.components?.banner?.colors ?? colors),
|
|
),
|
|
...Object.keys(colors ?? {}).map((key) =>
|
|
placeholderCSS(key as ColorName, theme.components?.placeholder?.colors ?? colors),
|
|
),
|
|
].join('\n\n');
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
|
|
return [`/* ${theme.name} */`, `[data-theme="${theme.id}"] {`, indent(themeCSS), '}'].join('\n');
|
|
}
|
|
|
|
export function addThemeStylesToDocument(theme: YaakTheme) {
|
|
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.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');
|
|
}
|