managarten/packages/shared-theme/src/utils.ts
Till JS 68c2442419
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
feat(workbench): paper-grain polish — blend-mode, border, stone palette
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>
2026-04-11 23:38:30 +02:00

270 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}