managarten/packages/shared-theme/src/utils.ts
2025-12-03 23:42:37 +01:00

252 lines
7 KiB
TypeScript

import type { ThemeColors, ThemeVariant, EffectiveMode, HSLValue, A11ySettings } from './types';
import { THEME_DEFINITIONS, CSS_VAR_PREFIX } from './constants';
import { applyA11yTransformations, applyA11yAttributes } from './a11y-utils';
/**
* Check if code is running in browser
*/
export function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined';
}
/**
* Get system color scheme preference
*/
export function getSystemPreference(): EffectiveMode {
if (!isBrowser()) return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Create a media query listener for system preference changes
*/
export function createSystemPreferenceListener(callback: (isDark: boolean) => void): () => void {
if (!isBrowser()) return () => {};
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => callback(e.matches);
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}
// Legacy browsers
mediaQuery.addListener(handler);
return () => mediaQuery.removeListener(handler);
}
/**
* Get colors for a specific variant and mode
*/
export function getThemeColors(
variant: ThemeVariant,
mode: EffectiveMode,
primaryOverride?: { light: HSLValue; dark: HSLValue }
): ThemeColors {
const definition = THEME_DEFINITIONS[variant];
const colors = mode === 'dark' ? { ...definition.dark } : { ...definition.light };
// Apply app-specific primary color override
if (primaryOverride) {
const overrideColor = mode === 'dark' ? primaryOverride.dark : primaryOverride.light;
colors.primary = overrideColor;
colors.ring = overrideColor;
}
return colors;
}
/**
* Convert ThemeColors to CSS variables object
*/
export function colorsToCssVars(colors: ThemeColors): Record<string, string> {
return {
[`${CSS_VAR_PREFIX}-primary`]: colors.primary,
[`${CSS_VAR_PREFIX}-primary-foreground`]: colors.primaryForeground,
[`${CSS_VAR_PREFIX}-secondary`]: colors.secondary,
[`${CSS_VAR_PREFIX}-secondary-foreground`]: colors.secondaryForeground,
[`${CSS_VAR_PREFIX}-background`]: colors.background,
[`${CSS_VAR_PREFIX}-foreground`]: colors.foreground,
[`${CSS_VAR_PREFIX}-surface`]: colors.surface,
[`${CSS_VAR_PREFIX}-surface-hover`]: colors.surfaceHover,
[`${CSS_VAR_PREFIX}-surface-elevated`]: colors.surfaceElevated,
[`${CSS_VAR_PREFIX}-muted`]: colors.muted,
[`${CSS_VAR_PREFIX}-muted-foreground`]: colors.mutedForeground,
[`${CSS_VAR_PREFIX}-border`]: colors.border,
[`${CSS_VAR_PREFIX}-border-strong`]: colors.borderStrong,
[`${CSS_VAR_PREFIX}-error`]: colors.error,
[`${CSS_VAR_PREFIX}-success`]: colors.success,
[`${CSS_VAR_PREFIX}-warning`]: colors.warning,
[`${CSS_VAR_PREFIX}-input`]: colors.input,
[`${CSS_VAR_PREFIX}-ring`]: colors.ring,
};
}
/**
* Apply theme to document
*/
export function applyThemeToDocument(
variant: ThemeVariant,
effectiveMode: EffectiveMode,
primaryOverride?: { light: HSLValue; dark: HSLValue },
a11ySettings?: A11ySettings
): void {
if (!isBrowser()) return;
const root = document.documentElement;
let colors = getThemeColors(variant, effectiveMode, primaryOverride);
// Apply A11y transformations if provided
if (a11ySettings) {
colors = applyA11yTransformations(colors, effectiveMode, a11ySettings);
applyA11yAttributes(a11ySettings);
}
const cssVars = colorsToCssVars(colors);
// Set CSS variables
Object.entries(cssVars).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
// Set data-theme attribute
root.setAttribute('data-theme', variant);
// Set dark class
if (effectiveMode === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// Update color-scheme for native elements
root.style.colorScheme = effectiveMode;
}
/**
* Load theme from localStorage
*/
export function loadThemeFromStorage(
storageKey: string
): { mode?: string; variant?: string } | null {
if (!isBrowser()) return null;
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load theme from storage:', e);
}
return null;
}
/**
* Save theme to localStorage
*/
export function saveThemeToStorage(storageKey: string, mode: string, variant: string): void {
if (!isBrowser()) return;
try {
localStorage.setItem(storageKey, JSON.stringify({ mode, variant }));
} catch (e) {
console.warn('Failed to save theme to storage:', e);
}
}
/**
* Parse HSL string to components
* Input: "47 95% 58%" -> { h: 47, s: 95, l: 58 }
*/
export function parseHSL(hsl: HSLValue): { h: number; s: number; l: number } {
const parts = hsl.split(' ');
return {
h: parseFloat(parts[0]),
s: parseFloat(parts[1]),
l: parseFloat(parts[2]),
};
}
/**
* Create HSL string from components
*/
export function createHSL(h: number, s: number, l: number): HSLValue {
return `${h} ${s}% ${l}%`;
}
/**
* Adjust lightness of an HSL color
*/
export function adjustLightness(hsl: HSLValue, amount: number): HSLValue {
const { h, s, l } = parseHSL(hsl);
const newL = Math.max(0, Math.min(100, l + amount));
return createHSL(h, s, newL);
}
/**
* Adjust saturation of an HSL color
*/
export function adjustSaturation(hsl: HSLValue, amount: number): HSLValue {
const { h, s, l } = parseHSL(hsl);
const newS = Math.max(0, Math.min(100, s + amount));
return createHSL(h, newS, l);
}
/**
* Get contrasting text color (black or white) for a background
*/
export function getContrastColor(backgroundHSL: HSLValue): HSLValue {
const { l } = parseHSL(backgroundHSL);
// Use white text for dark backgrounds, black for light
return l > 55 ? '0 0% 0%' : '0 0% 100%';
}
/**
* Generate CSS string for all theme variants
* Useful for generating static CSS files
*/
export function generateThemeCSS(
primaryOverrides?: Record<string, { light: HSLValue; dark: HSLValue }>
): string {
let css = '';
// Root (default Lume light)
const defaultColors = getThemeColors('lume', 'light', primaryOverrides?.['lume']);
const defaultVars = colorsToCssVars(defaultColors);
css += ':root {\n';
Object.entries(defaultVars).forEach(([key, value]) => {
css += ` ${key}: ${value};\n`;
});
css += '}\n\n';
// Each variant
for (const [variantName] of Object.entries(THEME_DEFINITIONS)) {
const variant = variantName as ThemeVariant;
const override = primaryOverrides?.[variant];
// Light mode
const lightColors = getThemeColors(variant, 'light', override);
const lightVars = colorsToCssVars(lightColors);
css += `[data-theme="${variant}"] {\n`;
Object.entries(lightVars).forEach(([key, value]) => {
css += ` ${key}: ${value};\n`;
});
css += '}\n\n';
// Dark mode
const darkColors = getThemeColors(variant, 'dark', override);
const darkVars = colorsToCssVars(darkColors);
css += `[data-theme="${variant}"].dark,\n.dark[data-theme="${variant}"] {\n`;
Object.entries(darkVars).forEach(([key, value]) => {
css += ` ${key}: ${value};\n`;
});
css += '}\n\n';
}
return css;
}