mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-18 07:23:51 +01:00
Start of themes
This commit is contained in:
@@ -29,7 +29,7 @@ export interface HttpResponse extends BaseModel {
|
||||
requestId: string;
|
||||
body: string;
|
||||
error: string;
|
||||
status: string;
|
||||
status: number;
|
||||
elapsed: number;
|
||||
statusReason: string;
|
||||
url: string;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
export function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') ?? getPreferredTheme();
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
}
|
||||
|
||||
export function setTheme(theme?: Theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme ?? getPreferredTheme());
|
||||
}
|
||||
|
||||
export function getPreferredTheme(): Theme {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function subscribeToPreferredThemeChange(cb: (theme: Theme) => void): () => void {
|
||||
const listener = (e: MediaQueryListEvent) => cb(e.matches ? 'dark' : 'light');
|
||||
const m = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
m.addEventListener('change', listener);
|
||||
return () => m.removeEventListener('change', listener);
|
||||
}
|
||||
39
src-web/lib/theme/theme.test.ts
Normal file
39
src-web/lib/theme/theme.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateColorVariant, toTailwindVariable } from './theme';
|
||||
|
||||
describe('suite name', () => {
|
||||
it('Generates dark variants', () => {
|
||||
expect(generateColorVariant('blue', 50, 'dark')).toEqual('hsl(240,100%,5.0%)');
|
||||
expect(generateColorVariant('blue', 100, 'dark')).toEqual('hsl(240,100%,10.0%)');
|
||||
expect(generateColorVariant('blue', 200, 'dark')).toEqual('hsl(240,100%,20.0%)');
|
||||
expect(generateColorVariant('blue', 300, 'dark')).toEqual('hsl(240,100%,30.0%)');
|
||||
expect(generateColorVariant('blue', 400, 'dark')).toEqual('hsl(240,100%,40.0%)');
|
||||
expect(generateColorVariant('blue', 500, 'dark')).toEqual('hsl(240,100%,50.0%)');
|
||||
expect(generateColorVariant('blue', 600, 'dark')).toEqual('hsl(240,100%,60.0%)');
|
||||
expect(generateColorVariant('blue', 700, 'dark')).toEqual('hsl(240,100%,70.0%)');
|
||||
expect(generateColorVariant('blue', 800, 'dark')).toEqual('hsl(240,100%,80.0%)');
|
||||
expect(generateColorVariant('blue', 900, 'dark')).toEqual('hsl(240,100%,90.0%)');
|
||||
expect(generateColorVariant('blue', 950, 'dark')).toEqual('hsl(240,100%,95.0%)');
|
||||
});
|
||||
it('Generates light variants', () => {
|
||||
expect(generateColorVariant('blue', 50, 'light')).toEqual('hsl(240,100%,95.0%)');
|
||||
expect(generateColorVariant('blue', 100, 'light')).toEqual('hsl(240,100%,90.0%)');
|
||||
expect(generateColorVariant('blue', 200, 'light')).toEqual('hsl(240,100%,80.0%)');
|
||||
expect(generateColorVariant('blue', 300, 'light')).toEqual('hsl(240,100%,70.0%)');
|
||||
expect(generateColorVariant('blue', 400, 'light')).toEqual('hsl(240,100%,60.0%)');
|
||||
expect(generateColorVariant('blue', 500, 'light')).toEqual('hsl(240,100%,50.0%)');
|
||||
expect(generateColorVariant('blue', 600, 'light')).toEqual('hsl(240,100%,40.0%)');
|
||||
expect(generateColorVariant('blue', 700, 'light')).toEqual('hsl(240,100%,30.0%)');
|
||||
expect(generateColorVariant('blue', 800, 'light')).toEqual('hsl(240,100%,20.0%)');
|
||||
expect(generateColorVariant('blue', 900, 'light')).toEqual('hsl(240,100%,10.0%)');
|
||||
expect(generateColorVariant('blue', 950, 'light')).toEqual('hsl(240,100%,5.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%;');
|
||||
});
|
||||
});
|
||||
115
src-web/lib/theme/theme.ts
Normal file
115
src-web/lib/theme/theme.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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 = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950;
|
||||
export const appThemeVariants: AppThemeColorVariant[] = [
|
||||
50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
|
||||
];
|
||||
|
||||
export type AppThemeLayer = 'root' | 'sidebar' | 'titlebar' | 'content' | 'above';
|
||||
|
||||
export interface AppThemeLayerStyle {
|
||||
colors: Record<AppThemeColor, string>;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
export function generateColors(
|
||||
name: AppThemeColor,
|
||||
color: string,
|
||||
appearance: Appearance,
|
||||
): ThemeColorObj[] {
|
||||
const colors = [];
|
||||
for (const variant of appThemeVariants) {
|
||||
colors.push({ name, variant, cssColor: generateColorVariant(color, variant, appearance) });
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
const lightnessMap: Record<Appearance, Record<AppThemeColorVariant, number>> = {
|
||||
light: {
|
||||
50: 1,
|
||||
100: 0.8,
|
||||
200: 0.7,
|
||||
300: 0.5,
|
||||
400: 0.3,
|
||||
500: 0.1,
|
||||
600: -0.2,
|
||||
700: -0.3,
|
||||
800: -0.5,
|
||||
900: -0.7,
|
||||
950: -0.8,
|
||||
},
|
||||
dark: {
|
||||
50: -0.95,
|
||||
100: -0.8,
|
||||
200: -0.6,
|
||||
300: -0.4,
|
||||
400: -0.2,
|
||||
500: 0,
|
||||
600: 0.2,
|
||||
700: 0.4,
|
||||
800: 0.5,
|
||||
900: 0.7,
|
||||
950: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
export function generateColorVariant(
|
||||
color: string,
|
||||
variant: AppThemeColorVariant,
|
||||
appearance: Appearance,
|
||||
): string {
|
||||
const { hsl } = parseColor(color || '');
|
||||
const lightnessMod = lightnessMap[appearance][variant];
|
||||
const newL = hsl[2] + (100 - hsl[2]) * 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]}%;`;
|
||||
}
|
||||
92
src-web/lib/theme/window.ts
Normal file
92
src-web/lib/theme/window.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { AppTheme } from './theme';
|
||||
import { generateCSS, toTailwindVariable } from './theme';
|
||||
|
||||
export type Appearance = 'dark' | 'light';
|
||||
|
||||
const darkTheme: AppTheme = {
|
||||
name: 'Default Dark',
|
||||
appearance: 'dark',
|
||||
layers: {
|
||||
root: {
|
||||
colors: {
|
||||
gray: '#69789b',
|
||||
red: '#ff1c1c',
|
||||
orange: '#ff9411',
|
||||
yellow: '#ffff1f',
|
||||
green: '#35ff35',
|
||||
blue: '#1365ff',
|
||||
pink: '#ff74ff',
|
||||
violet: '#873fff',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const lightTheme: AppTheme = {
|
||||
name: 'Default Light',
|
||||
appearance: 'light',
|
||||
layers: {
|
||||
root: {
|
||||
colors: {
|
||||
gray: '#69789b',
|
||||
red: '#e13939',
|
||||
orange: '#da881f',
|
||||
yellow: '#e3b22d',
|
||||
green: '#37c237',
|
||||
blue: '#1365ff',
|
||||
pink: '#e861e8',
|
||||
violet: '#8d47ff',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getAppearance(): Appearance {
|
||||
const docAppearance = document.documentElement.getAttribute('data-appearance');
|
||||
if (docAppearance === 'dark' || docAppearance === 'light') {
|
||||
return docAppearance;
|
||||
}
|
||||
return getPreferredAppearance();
|
||||
}
|
||||
|
||||
export function toggleAppearance(): Appearance {
|
||||
const currentTheme =
|
||||
document.documentElement.getAttribute('data-appearance') ?? getPreferredAppearance();
|
||||
const newAppearance = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setAppearance(newAppearance);
|
||||
return newAppearance;
|
||||
}
|
||||
|
||||
export function setAppearance(a?: Appearance) {
|
||||
const appearance = a ?? getPreferredAppearance();
|
||||
const theme = appearance === 'dark' ? darkTheme : lightTheme;
|
||||
document.documentElement.setAttribute('data-appearance', appearance);
|
||||
document.documentElement.setAttribute('data-theme', theme.name);
|
||||
|
||||
let existingStyleEl = document.head.querySelector(`style[data-theme-definition="${theme.name}"]`);
|
||||
if (!existingStyleEl) {
|
||||
const styleEl = document.createElement('style');
|
||||
document.head.appendChild(styleEl);
|
||||
existingStyleEl = styleEl;
|
||||
}
|
||||
|
||||
existingStyleEl.textContent = [
|
||||
`[data-theme="${theme.name}"] {`,
|
||||
...generateCSS(theme).map(toTailwindVariable),
|
||||
'}',
|
||||
].join('\n');
|
||||
existingStyleEl.setAttribute('data-theme-definition', theme.name);
|
||||
}
|
||||
|
||||
export function getPreferredAppearance(): Appearance {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function subscribeToPreferredAppearanceChange(
|
||||
cb: (appearance: Appearance) => void,
|
||||
): () => void {
|
||||
const listener = (e: MediaQueryListEvent) => cb(e.matches ? 'dark' : 'light');
|
||||
const m = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
m.addEventListener('change', listener);
|
||||
return () => m.removeEventListener('change', listener);
|
||||
}
|
||||
Reference in New Issue
Block a user