Theme system refactor (#31)

This commit is contained in:
Gregory Schier
2024-05-21 17:56:06 -07:00
committed by GitHub
parent 8606940dee
commit 83aaeb94f6
82 changed files with 909 additions and 739 deletions

View File

@@ -0,0 +1,94 @@
import parseColor from 'parse-color';
export class Color {
private theme: 'dark' | 'light' = 'light';
private hue: number = 0;
private saturation: number = 0;
private lightness: number = 0;
private alpha: number = 1;
constructor(cssColor: string, theme: 'dark' | 'light') {
try {
const { hsla } = parseColor(cssColor || '');
this.hue = hsla[0];
this.saturation = hsla[1];
this.lightness = hsla[2];
this.alpha = hsla[3] ?? 1;
this.theme = theme;
} catch (err) {
console.log('Failed to parse CSS color', cssColor, err);
}
}
static transparent(): Color {
return new Color('rgba(0, 0, 0, 0.1)', 'light');
}
private clone(): Color {
return new Color(this.css(), this.theme);
}
lower(mod: number): Color {
return this.theme === 'dark' ? this._darken(mod) : this._lighten(mod);
}
lowerTo(value: number): Color {
return this.theme === 'dark'
? this._darken(1)._lighten(value)
: this._lighten(1)._darken(1 - value);
}
lift(mod: number): Color {
return this.theme === 'dark' ? this._lighten(mod) : this._darken(mod);
}
liftTo(value: number): Color {
return this.theme === 'dark'
? this._lighten(1)._darken(1 - value)
: this._darken(1)._lighten(value);
}
translucify(mod: number): Color {
const c = this.clone();
c.alpha = c.alpha - c.alpha * mod;
return c;
}
desaturate(mod: number): Color {
const c = this.clone();
c.saturation = c.saturation - c.saturation * mod;
return c;
}
saturate(mod: number): Color {
const c = this.clone();
c.saturation = this.saturation + (100 - this.saturation) * mod;
return c;
}
lighterThan(c: Color): boolean {
return this.lightness > c.lightness;
}
css(): string {
// If opacity is 1, allow for Tailwind modification
const h = Math.round(this.hue);
const s = Math.round(this.saturation);
const l = Math.round(this.lightness);
const a = Math.round(this.alpha * 100) / 100;
return `hsla(${h}, ${s}%, ${l}%, ${a})`;
}
private _lighten(mod: number): Color {
const c = this.clone();
c.lightness = this.lightness + (100 - this.lightness) * mod;
return c;
}
private _darken(mod: number): Color {
const c = this.clone();
c.lightness = this.lightness - this.lightness * mod;
return c;
}
}

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import { generateColorVariant, toTailwindVariable } from './theme';
describe('Generate colors', () => {
it('Generates dark colors', () => {
expect(generateColorVariant('hsl(0,0%,50%)', 50, 'dark', 0.2, 0.8)).toBe('hsl(0,0%,14.0%)');
expect(generateColorVariant('hsl(0,0%,50%)', 950, 'dark', 0.2, 0.8)).toBe('hsl(0,0%,77.0%)');
expect(generateColorVariant('hsl(0,0%,50%)', 50, 'dark', 0.4, 0.6)).toBe('hsl(0,0%,23.0%)');
expect(generateColorVariant('hsl(0,0%,50%)', 950, 'dark', 0.4, 0.6)).toBe('hsl(0,0%,59.0%)');
});
it('Generates light colors', () => {
expect(generateColorVariant('hsl(0,0%,50%)', 50, 'light', 0.2, 0.8)).toBe('hsl(0,0%,80.0%)');
expect(generateColorVariant('hsl(0,0%,50%)', 950, 'light', 0.2, 0.8)).toBe('hsl(0,0%,14.0%)');
expect(generateColorVariant('hsl(0,0%,50%)', 50, 'light', 0.4, 0.6)).toBe('hsl(0,0%,60.0%)');
expect(generateColorVariant('hsl(0,0%,50%)', 950, 'light', 0.4, 0.6)).toBe('hsl(0,0%,23.0%)');
});
});
describe('Generates Tailwind color', () => {
it('Does it', () => {
expect(
toTailwindVariable({ name: 'blue', cssColor: 'hsl(10, 20%, 30%)', variant: 100 }),
).toEqual('--color-blue-100: 10 20% 30%;');
});
});

View File

@@ -1,172 +0,0 @@
import parseColor from 'parse-color';
import type { Appearance } from './window';
export type AppThemeColor =
| 'gray'
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'blue'
| 'pink'
| 'violet';
const colorNames: AppThemeColor[] = [
'gray',
'red',
'orange',
'yellow',
'green',
'blue',
'pink',
'violet',
];
export type AppThemeColorVariant =
| 0
| 50
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900
| 950
| 1000;
export const appThemeVariants: AppThemeColorVariant[] = [
0, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, 1000,
];
export type AppThemeLayer = 'root' | 'sidebar' | 'titlebar' | 'content' | 'above';
export type AppThemeColors = Record<AppThemeColor, string>;
export interface AppThemeLayerStyle {
colors: AppThemeColors;
blackPoint?: number;
whitePoint?: number;
}
interface ThemeColorObj {
name: AppThemeColor;
variant: AppThemeColorVariant;
cssColor: string;
}
export interface AppTheme {
name: string;
appearance: Appearance;
layers: Partial<Record<AppThemeLayer, AppThemeLayerStyle>>;
}
export function generateCSS(t: AppTheme): ThemeColorObj[] {
const rootColors = t.layers.root?.colors;
if (rootColors === undefined) return [];
const colors: ThemeColorObj[] = [];
for (const color of colorNames) {
const rawValue = rootColors[color];
if (!rawValue) continue;
colors.push(
...generateColors(
color,
rawValue,
t.appearance,
t.layers.root?.blackPoint,
t.layers.root?.whitePoint,
),
);
}
return colors;
}
export function generateColors(
name: AppThemeColor,
color: string,
appearance: Appearance,
blackPoint = 0,
whitePoint = 1,
): ThemeColorObj[] {
const colors = [];
for (const variant of appThemeVariants) {
colors.push({
name,
variant,
cssColor: generateColorVariant(color, variant, appearance, blackPoint, whitePoint),
});
}
return colors;
}
const lightnessMap: Record<Appearance, Record<AppThemeColorVariant, number>> = {
system: {
// Not actually used
0: 1,
50: 1,
100: 0.9,
200: 0.7,
300: 0.4,
400: 0.2,
500: 0,
600: -0.2,
700: -0.4,
800: -0.6,
900: -0.8,
950: -0.9,
1000: -1,
},
light: {
0: 1,
50: 1,
100: 0.9,
200: 0.7,
300: 0.4,
400: 0.2,
500: 0,
600: -0.2,
700: -0.4,
800: -0.6,
900: -0.8,
950: -0.9,
1000: -1,
},
dark: {
0: -1,
50: -0.9,
100: -0.8,
200: -0.6,
300: -0.4,
400: -0.2,
500: 0,
600: 0.2,
700: 0.4,
800: 0.6,
900: 0.8,
950: 0.9,
1000: 1,
},
};
export function generateColorVariant(
color: string,
variant: AppThemeColorVariant,
appearance: Appearance,
blackPoint = 0,
whitePoint = 1,
): string {
const { hsl } = parseColor(color || '');
const lightnessMod = lightnessMap[appearance][variant];
// const lightnessMod = (appearance === 'dark' ? 1 : -1) * ((variant / 1000) * 2 - 1);
const newL =
lightnessMod > 0
? hsl[2] + (100 * whitePoint - hsl[2]) * lightnessMod
: hsl[2] + hsl[2] * (1 - blackPoint) * lightnessMod;
return `hsl(${hsl[0]},${hsl[1]}%,${newL.toFixed(1)}%)`;
}
export function toTailwindVariable({ name, variant, cssColor }: ThemeColorObj): string {
const { hsl } = parseColor(cssColor || '');
return `--color-${name}-${variant}: ${hsl[0]} ${hsl[1]}% ${hsl[2]}%;`;
}

View File

@@ -1,76 +1,313 @@
import type { AppTheme, AppThemeColors } from './theme';
import { generateCSS, toTailwindVariable } from './theme';
import { indent } from '../indent';
import { Color } from './color';
export type Appearance = 'dark' | 'light' | 'system';
const DEFAULT_APPEARANCE: Appearance = 'system';
enum Theme {
yaak = 'yaak',
catppuccin = 'catppuccin',
interface ThemeComponent {
background?: Color;
backgroundHighlight?: Color;
backgroundHighlightSecondary?: Color;
backgroundActive?: Color;
foreground?: Color;
foregroundSubtle?: Color;
foregroundSubtler?: Color;
colors?: Partial<RootColors>;
}
const themes: Record<Theme, AppThemeColors> = {
yaak: {
gray: 'hsl(245, 23%, 45%)',
red: 'hsl(342,100%, 63%)',
orange: 'hsl(32, 98%, 54%)',
yellow: 'hsl(52, 79%, 58%)',
green: 'hsl(136, 62%, 54%)',
blue: 'hsl(206, 100%, 56%)',
pink: 'hsl(300, 100%, 71%)',
violet: 'hsl(266, 100%, 73%)',
},
catppuccin: {
gray: 'hsl(240, 23%, 47%)',
red: 'hsl(343, 91%, 74%)',
orange: 'hsl(23, 92%, 74%)',
yellow: 'hsl(41, 86%, 72%)',
green: 'hsl(115, 54%, 65%)',
blue: 'hsl(217, 92%, 65%)',
pink: 'hsl(316, 72%, 75%)',
violet: 'hsl(267, 84%, 70%)',
},
};
interface YaakTheme extends ThemeComponent {
name: string;
components?: {
dialog?: ThemeComponent;
sidebar?: ThemeComponent;
responsePane?: ThemeComponent;
appHeader?: ThemeComponent;
button?: ThemeComponent;
banner?: ThemeComponent;
placeholder?: ThemeComponent;
};
}
const darkTheme: AppTheme = {
name: 'Default Dark',
appearance: 'dark',
layers: {
root: {
blackPoint: 0.2,
colors: themes.yaak,
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']>;
const yaakThemes: Record<string, YaakTheme> = {
yaakLight: {
name: 'Yaak (Light)',
background: new Color('#f2f4f7', 'light').lower(1),
foreground: new Color('hsl(219,23%,15%)', 'light'),
colors: {
primary: new Color('hsl(266,100%,70%)', 'light'),
secondary: new Color('hsl(220,24%,59%)', 'light'),
info: new Color('hsl(206,100%,48%)', 'light'),
success: new Color('hsl(155,95%,33%)', 'light'),
notice: new Color('hsl(45,100%,41%)', 'light'),
warning: new Color('hsl(30,100%,43%)', 'light'),
danger: new Color('hsl(335,75%,57%)', 'light'),
},
components: {
sidebar: {
background: new Color('#f2f4f7', 'light'),
},
},
} as YaakTheme,
yaakDark: {
name: 'Yaak Dark',
background: new Color('hsl(244,23%,12%)', 'dark'),
foreground: new Color('#bcbad4', 'dark'),
colors: {
primary: new Color('hsl(266,100%,79%)', 'dark'),
secondary: new Color('hsl(245,23%,60%)', 'dark'),
info: new Color('hsl(206,100%,63%)', 'dark'),
success: new Color('hsl(150,100%,37%)', 'dark'),
notice: new Color('hsl(48,80%,63%)', 'dark'),
warning: new Color('hsl(28,100%,61%)', 'dark'),
danger: new Color('hsl(342,90%,68%)', 'dark'),
},
components: {
sidebar: {
background: new Color('hsl(243,23%,15%)', 'dark'),
},
responsePane: {
background: new Color('hsl(243,23%,15%)', 'dark'),
},
},
},
};
const lightTheme: AppTheme = {
name: 'Default Light',
appearance: 'light',
layers: {
root: {
colors: {
gray: '#7f8fb0',
red: '#ec3f87',
orange: '#ff8000',
yellow: '#e7cf24',
green: '#00d365',
blue: '#0090ff',
pink: '#ea6cea',
violet: '#ac6cff',
catppuccin: {
name: 'Catppuccin',
background: new Color('#181825', 'dark'),
foreground: new Color('#cdd6f4', 'dark'),
foregroundSubtle: new Color('#cdd6f4', 'dark').lower(0.1).translucify(0.3),
foregroundSubtler: new Color('#cdd6f4', 'dark').lower(0.1).translucify(0.55),
colors: {
primary: new Color('#cba6f7', 'dark'),
secondary: new Color('#bac2de', 'dark'),
info: new Color('#89b4fa', 'dark'),
success: new Color('#a6e3a1', 'dark'),
notice: new Color('#f9e2af', 'dark'),
warning: new Color('#fab387', 'dark'),
danger: new Color('#f38ba8', 'dark'),
},
components: {
dialog: {
background: new Color('#181825', 'dark'),
},
sidebar: {
background: new Color('#1e1e2e', 'dark'),
},
appHeader: {
background: new Color('#11111b', 'dark'),
},
responsePane: {
background: new Color('#1e1e2e', 'dark'),
},
button: {
colors: {
primary: new Color('#cba6f7', 'dark').lower(0.2),
secondary: new Color('#bac2de', 'dark').lower(0.2),
info: new Color('#89b4fa', 'dark').lower(0.2),
success: new Color('#a6e3a1', 'dark').lower(0.2),
notice: new Color('#f9e2af', 'dark').lower(0.2),
warning: new Color('#fab387', 'dark').lower(0.2),
danger: new Color('#f38ba8', 'dark').lower(0.2),
},
},
},
},
};
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(),
};
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-widget--${color}`, placeholderColorVariables(cssColor)),
].join('\n\n');
}
function isThemeDark(theme: YaakTheme): boolean {
if (theme.background && theme.foreground) {
return theme.foreground.lighterThan(theme.background);
}
return false;
}
setThemeOnDocument(yaakThemes.yaakLight!);
setThemeOnDocument(yaakThemes.yaakDark!);
export function getThemeCSS(theme: YaakTheme): string {
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 themeCSS;
}
export function setAppearanceOnDocument(appearance: Appearance = DEFAULT_APPEARANCE) {
const resolvedAppearance = appearance === 'system' ? getPreferredAppearance() : appearance;
const theme = resolvedAppearance === 'dark' ? darkTheme : lightTheme;
document.documentElement.setAttribute('data-resolved-appearance', resolvedAppearance);
}
export function setThemeOnDocument(theme: YaakTheme) {
document.documentElement.setAttribute('data-theme', theme.name);
let existingStyleEl = document.head.querySelector(`style[data-theme-definition]`);
const darkOrLight = isThemeDark(theme) ? 'dark' : 'light';
let existingStyleEl = document.head.querySelector(`style[data-theme-definition=${darkOrLight}]`);
if (!existingStyleEl) {
const styleEl = document.createElement('style');
document.head.appendChild(styleEl);
@@ -78,16 +315,12 @@ export function setAppearanceOnDocument(appearance: Appearance = DEFAULT_APPEARA
}
existingStyleEl.textContent = [
`/* ${darkTheme.name} */`,
`[data-resolved-appearance="dark"] {`,
...generateCSS(darkTheme).map(toTailwindVariable),
'}',
`/* ${lightTheme.name} */`,
`[data-resolved-appearance="light"] {`,
...generateCSS(lightTheme).map(toTailwindVariable),
`/* ${theme.name} */`,
`[data-resolved-appearance="${isThemeDark(theme) ? 'dark' : 'light'}"] {`,
getThemeCSS(theme),
'}',
].join('\n');
existingStyleEl.setAttribute('data-theme-definition', '');
existingStyleEl.setAttribute('data-theme-definition', darkOrLight);
}
export function getPreferredAppearance(): Appearance {