mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 19:39:40 +02:00
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Auth flow integration test (push) Waiting to run
CI / Build mana-auth (push) Blocked by required conditions
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-notify (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
CI / Build mana-media (push) Blocked by required conditions
CI / Build mana-credits (push) Blocked by required conditions
CI / Build mana-web (push) Blocked by required conditions
CI / Build chat-backend (push) Blocked by required conditions
CI / Build chat-web (push) Blocked by required conditions
CI / Build todo-backend (push) Blocked by required conditions
CI / Build todo-web (push) Blocked by required conditions
CI / Build calendar-backend (push) Blocked by required conditions
CI / Build calendar-web (push) Blocked by required conditions
CI / Build clock-web (push) Blocked by required conditions
CI / Build contacts-backend (push) Blocked by required conditions
CI / Build contacts-web (push) Blocked by required conditions
CI / Build presi-web (push) Blocked by required conditions
CI / Build storage-backend (push) Blocked by required conditions
CI / Build storage-web (push) Blocked by required conditions
CI / Build telegram-stats-bot (push) Blocked by required conditions
CI / Build nutriphi-backend (push) Blocked by required conditions
CI / Build nutriphi-web (push) Blocked by required conditions
CI / Build skilltree-web (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build zitare-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
Switch PageShell's per-theme paper overlay from a ::before + mix-blend-mode + opacity stack to direct background-blend-mode on the element itself. The old approach had invisibility issues in dark mode and stacking-context quirks that made the grain disappear entirely. background-blend-mode against background-color is the simpler, more reliable primitive. utils.ts auto-switches multiply → overlay in dark mode (dark × dark is essentially invisible) while leaving other blend modes as-is. The opacityLight/opacityDark knobs are gone from the paper config since background-blend-mode has no opacity slot — tune via blendMode choice instead. Visual tuning pass: - Card border bumped from 1px box-shadow ring to a real 2px border with background-clip: border-box so the paper texture reads continuously across the edge. Alpha 0.12 light / 0.28 dark (black). - Drop shadow deepened (0 8px 24px + 0 3px 8px) for more card lift. - Stone theme cooled toward real slate-blue: hue 200 → 212, saturation bumped ~10pts across the palette. Stone was reading as warm-neutral grey, now it's a proper cold blue. - Texture remap: Lume → paper-004 (strongest grain, 480px tile for coarser fiber), Stone → cardboard-002 (linen), Lavender → paper-001 (freed up after Stone claimed cardboard-002). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
270 lines
7.9 KiB
TypeScript
270 lines
7.9 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 per-theme paper-grain CSS variables (consumed by PageShell).
|
||
// Dark mode auto-switches multiply → overlay because multiply on a
|
||
// dark backdrop is practically invisible (dark × dark ≈ dark). Other
|
||
// blend modes (soft-light/screen/…) are respected as-is.
|
||
const paper = THEME_DEFINITIONS[variant]?.paper;
|
||
if (paper) {
|
||
const configuredBlend = paper.blendMode ?? 'multiply';
|
||
const effectiveBlend =
|
||
effectiveMode === 'dark' && configuredBlend === 'multiply' ? 'overlay' : configuredBlend;
|
||
root.style.setProperty('--paper-texture', `url("${paper.url}")`);
|
||
root.style.setProperty('--paper-blend-mode', effectiveBlend);
|
||
root.style.setProperty('--paper-size', paper.size ?? '240px 240px');
|
||
} else {
|
||
root.style.removeProperty('--paper-texture');
|
||
root.style.removeProperty('--paper-blend-mode');
|
||
root.style.removeProperty('--paper-size');
|
||
}
|
||
|
||
// 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;
|
||
}
|