Files
MindSift/frontend/src/lib/theme.ts
T
sandrodachandClaude Opus 5.5 3f0e596356
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
feat(themes): colour families with light/dark, follow system, corners, frames, accent
- Fjord (new), Polarized Hell and Suri Hell complete the families
  Klassik, Fjord, Terrakotta, Polarized, Suri
- settings: family table, follow-system, corners, button frames and an own
  accent colour (per device); compact quick picker in the user menus
- code colours in index.css are fallbacks only

chore(release): v0.14.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:29:52 +01:00

410 lines
14 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 { UserTheme } from './types';
// Built-in themes are compiled CSS files (src/themes/); custom themes are user CSS from the server,
// injected at runtime and addressed as "custom:<id>"
export type ThemeId = string;
export interface ThemeOption {
id: ThemeId;
name: string;
subtitle: string;
file: string;
previewBg: string;
previewPanel: string;
previewAccent: string;
previewText: string;
isRounded: boolean;
isCustom?: boolean;
/** Built-in themes come in families with a light and a dark variant */
family?: string;
mode?: 'light' | 'dark';
}
export interface ThemeFamily {
id: string;
name: string;
subtitle: string;
light: ThemeId;
dark: ThemeId;
}
// Left light, right dark – the settings and the quick switcher show them in this order
export const THEME_FAMILIES: ThemeFamily[] = [
{ id: 'classic', name: 'Klassik', subtitle: 'Neutral mit Blau, eckig', light: 'light', dark: 'dark' },
{ id: 'fjord', name: 'Fjord', subtitle: 'Kühles Papier, Gletscherblau', light: 'fjord', dark: 'fjord-night' },
{ id: 'terracotta', name: 'Terrakotta', subtitle: 'Warmes Papier, gebrannte Erde', light: 'terracotta', dark: 'terracotta-night' },
{ id: 'polarized', name: 'Polarized', subtitle: 'Petrol und Türkis, eckig', light: 'polarized-light', dark: 'polarized' },
{ id: 'suri', name: 'Suri', subtitle: 'Lavendel, Pflaume, Orchidee', light: 'suri-light', dark: 'suri' },
];
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
// CSS of the active custom theme, so it applies on boot before the API answers (no flash)
const CUSTOM_CSS_CACHE_KEY = 'mindsift_custom_theme_css';
const CUSTOM_STYLE_ID = 'mindsift-custom-theme';
export const CUSTOM_THEME_PREFIX = 'custom:';
export const AVAILABLE_THEMES: ThemeOption[] = [
{
id: 'dark',
name: 'MindSift Dark (Standard)',
subtitle: 'Monochrom, dunkel & eckig (0px)',
file: 'src/themes/theme-dark.css',
previewBg: '#121316',
previewPanel: '#1a1c20',
previewAccent: '#2563eb',
previewText: '#f4f4f5',
isRounded: false,
},
{
id: 'light',
name: 'MindSift Light (Tagmodus)',
subtitle: 'Helles Papier-Design & klare Kontraste',
file: 'src/themes/theme-light.css',
previewBg: '#f3f4f6',
previewPanel: '#ffffff',
previewAccent: '#2563eb',
previewText: '#111827',
isRounded: false,
},
{
id: 'terracotta',
name: 'Terrakotta (Warmes Papier)',
subtitle: 'Cremepapier, Tinte & gebrannte Terrakotta (12px)',
file: 'src/themes/theme-terracotta.css',
previewBg: '#efe6d8',
previewPanel: '#fbf6ee',
previewAccent: '#c2410c',
previewText: '#2b2118',
isRounded: true,
},
{
id: 'terracotta-night',
name: 'Terrakotta Nacht (Glut)',
subtitle: 'Espresso, Creme & glühende Terrakotta (12px)',
file: 'src/themes/theme-terracotta-night.css',
previewBg: '#17110d',
previewPanel: '#211913',
previewAccent: '#e8794a',
previewText: '#f3e8da',
isRounded: true,
},
{
id: 'polarized',
name: 'Polarized (Emerald / Teal)',
subtitle: 'Tiefdunkles Obsidian mit elektrischem Türkis/Grün (0px)',
file: 'src/themes/theme-polarized.css',
previewBg: '#090e11',
previewPanel: '#10181c',
previewAccent: '#14b8a6',
previewText: '#f0fdfa',
isRounded: false,
},
{
id: 'suri',
name: 'Suri (Velvet Dusk)',
subtitle: 'Warmes Mitternachts-Pflaume mit samtiger Orchidee (10px)',
file: 'src/themes/theme-suri.css',
previewBg: '#130f1a',
previewPanel: '#1e1728',
previewAccent: '#b86bf8',
previewText: '#fbf7ff',
isRounded: true,
},
{
id: 'fjord',
name: 'Fjord (Gletscherblau)',
subtitle: 'Kühles Papier, Tintenblau & Fjordblau (12px)',
file: 'src/themes/theme-fjord.css',
previewBg: '#e9eef5',
previewPanel: '#f7f9fc',
previewAccent: '#2b6cb0',
previewText: '#14202e',
isRounded: true,
},
{
id: 'fjord-night',
name: 'Fjord Nacht (Tiefsee)',
subtitle: 'Nachtblau, Mondlicht & leuchtendes Gletscherblau (12px)',
file: 'src/themes/theme-fjord-night.css',
previewBg: '#0e1621',
previewPanel: '#152131',
previewAccent: '#5b9bdf',
previewText: '#e6eef8',
isRounded: true,
},
{
id: 'polarized-light',
name: 'Polarized Hell (Lagune)',
subtitle: 'Kreidiges Mintweiß mit tiefem Petrol (0px)',
file: 'src/themes/theme-polarized-light.css',
previewBg: '#e6f0ef',
previewPanel: '#f6fbfa',
previewAccent: '#0f766e',
previewText: '#0f2422',
isRounded: false,
},
{
id: 'suri-light',
name: 'Suri Hell (Lavendel)',
subtitle: 'Lavendelpapier, Pflaume & Orchidee (10px)',
file: 'src/themes/theme-suri-light.css',
previewBg: '#efe9f4',
previewPanel: '#fbf8fd',
previewAccent: '#8e44c9',
previewText: '#23182e',
isRounded: true,
},
].map((t) => {
const family = THEME_FAMILIES.find((f) => f.light === t.id || f.dark === t.id);
return family ? { ...t, family: family.id, mode: family.light === t.id ? ('light' as const) : ('dark' as const) } : t;
});
/** The same family in the other mode (light ↔ dark); custom themes have none */
export function themeCounterpart(id: ThemeId): ThemeId | null {
const f = THEME_FAMILIES.find((x) => x.light === id || x.dark === id);
if (!f) return null;
return f.light === id ? f.dark : f.light;
}
// Light/dark follows the system inside the chosen family (per device)
const FOLLOW_SYSTEM_KEY = 'mindsift_theme_follow_system';
export function getFollowSystem(): boolean {
return load(FOLLOW_SYSTEM_KEY) === '1';
}
export function setFollowSystem(on: boolean): void {
store(FOLLOW_SYSTEM_KEY, on ? '1' : null);
}
function systemPrefersDark(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
/** With "follow system": the family's variant that matches the system; otherwise the theme itself */
export function resolveTheme(id: ThemeId): ThemeId {
if (!getFollowSystem()) return id;
const f = THEME_FAMILIES.find((x) => x.light === id || x.dark === id);
if (!f) return id;
return systemPrefersDark() ? f.dark : f.light;
}
/** Switches the theme when the system changes between light and dark; returns the unsubscribe */
export function watchSystemScheme(onChange: (id: ThemeId) => void): () => void {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
if (!getFollowSystem()) return;
const next = resolveTheme(getActiveTheme());
if (next !== getActiveTheme()) {
setActiveTheme(next);
onChange(next);
}
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}
// Buttons with or without frames (per device): "off" removes the border of the regular buttons
export type ButtonBorders = 'on' | 'off';
const BUTTON_BORDERS_KEY = 'mindsift_button_borders';
export function getButtonBorders(): ButtonBorders {
return load(BUTTON_BORDERS_KEY) === 'off' ? 'off' : 'on';
}
export function setButtonBorders(value: ButtonBorders): void {
store(BUTTON_BORDERS_KEY, value === 'off' ? 'off' : null);
applyButtonBorders(value);
}
function applyButtonBorders(value: ButtonBorders): void {
if (typeof document === 'undefined') return;
if (value === 'off') document.documentElement.setAttribute('data-button-borders', 'off');
else document.documentElement.removeAttribute('data-button-borders');
}
// Own accent colour on top of any theme (per device); null = the theme's accent
const ACCENT_KEY = 'mindsift_accent';
const ACCENT_VARS = ['--accent-primary', '--accent-hover', '--accent-dim', '--accent-border', '--text-on-accent'];
export function getAccentColor(): string | null {
const v = load(ACCENT_KEY);
return v && /^#[0-9a-f]{6}$/i.test(v) ? v : null;
}
export function setAccentColor(hex: string | null): void {
store(ACCENT_KEY, hex);
applyAccent(hex);
}
function hexToRgb(hex: string): [number, number, number] {
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number];
}
function applyAccent(hex: string | null): void {
if (typeof document === 'undefined') return;
const style = document.documentElement.style;
if (!hex) {
ACCENT_VARS.forEach((v) => style.removeProperty(v));
return;
}
const [r, g, b] = hexToRgb(hex);
// Hover goes towards the text colour: darker on light themes, lighter on dark ones
const dark = document.documentElement.classList.contains('dark');
const mix = (c: number) => Math.round(dark ? c + (255 - c) * 0.2 : c * 0.82);
const toHex = (c: number) => c.toString(16).padStart(2, '0');
style.setProperty('--accent-primary', hex);
style.setProperty('--accent-hover', `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`);
style.setProperty('--accent-dim', `rgba(${r}, ${g}, ${b}, ${dark ? 0.16 : 0.1})`);
style.setProperty('--accent-border', `rgba(${r}, ${g}, ${b}, 0.38)`);
style.setProperty('--text-on-accent', 0.2126 * r + 0.7152 * g + 0.0722 * b > 160 ? '#111111' : '#ffffff');
}
// Corners: follow the theme, or force round / square for every theme (per device)
export type CornerStyle = 'theme' | 'round' | 'square';
const CORNERS_STORAGE_KEY = 'mindsift_corners';
export function getCornerStyle(): CornerStyle {
const v = load(CORNERS_STORAGE_KEY);
return v === 'round' || v === 'square' ? v : 'theme';
}
export function setCornerStyle(style: CornerStyle): void {
store(CORNERS_STORAGE_KEY, style === 'theme' ? null : style);
applyCorners(style);
}
function applyCorners(style: CornerStyle): void {
if (typeof document === 'undefined') return;
if (style === 'theme') document.documentElement.removeAttribute('data-corners');
else document.documentElement.setAttribute('data-corners', style);
}
export const isCustomThemeId = (id: ThemeId) => id.startsWith(CUSTOM_THEME_PREFIX);
export const customThemeId = (t: UserTheme): ThemeId => CUSTOM_THEME_PREFIX + t.id;
// Comments are ignored when reading the CSS (the template mentions [data-theme="mein-theme"] in one)
const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, '');
const readCssVar = (css: string, name: string): string | undefined =>
stripComments(css).match(new RegExp(`--${name}\\s*:\\s*([^;]+);`))?.[1].trim();
// The CSS decides its own [data-theme="…"]; without one it targets plain :root
const themeAttribute = (css: string): string =>
stripComments(css).match(/data-theme\s*=\s*["']?([\w-]+)/)?.[1] || 'custom';
// Light background -> no Tailwind dark class
function isLightColor(color?: string): boolean {
const hex = color?.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (!hex) return false;
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 140;
}
export function customThemeOption(t: UserTheme): ThemeOption {
const radius = readCssVar(t.css, 'radius-card');
return {
id: customThemeId(t),
name: t.name,
subtitle: 'Eigenes Theme',
file: 'Eigenes Theme (gespeichert auf dem Server)',
previewBg: readCssVar(t.css, 'bg-app') || '#121316',
previewPanel: readCssVar(t.css, 'bg-panel') || '#1a1c20',
previewAccent: readCssVar(t.css, 'accent-primary') || '#2563eb',
previewText: readCssVar(t.css, 'text-main') || '#f4f4f5',
isRounded: !!radius && !/^0(px)?$/.test(radius),
isCustom: true,
};
}
function injectCustomCss(css: string | null): void {
if (typeof document === 'undefined') return;
let el = document.getElementById(CUSTOM_STYLE_ID) as HTMLStyleElement | null;
if (css === null) {
el?.remove();
return;
}
if (!el) {
el = document.createElement('style');
el.id = CUSTOM_STYLE_ID;
}
el.textContent = css;
// Last in <head>, so it wins over the compiled themes
document.head.appendChild(el);
}
function applyToDocument(themeId: ThemeId, customCss: string | null): void {
if (typeof document === 'undefined') return;
injectCustomCss(customCss);
const root = document.documentElement;
const attr = customCss !== null ? themeAttribute(customCss) : themeId;
root.setAttribute('data-theme', attr);
const light =
customCss !== null
? isLightColor(readCssVar(customCss, 'bg-app'))
: isLightColor(AVAILABLE_THEMES.find((t) => t.id === themeId)?.previewBg);
root.classList.toggle('dark', !light);
applyAccent(getAccentColor());
syncThemeColorMeta();
}
// The status bar (theme-color) starts black (like the Android navigation bar of the installed app,
// which only takes the manifest colour); once the app runs it follows the theme's background.
function syncThemeColorMeta(): void {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta || typeof requestAnimationFrame === 'undefined') return;
requestAnimationFrame(() => {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-app').trim();
if (bg) meta.setAttribute('content', bg);
});
}
function store(key: string, value: string | null): void {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch {
// ignore
}
}
function load(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
// Removed themes and their successors
const RETIRED_THEMES: Record<string, ThemeId> = { soft: 'terracotta-night', 'high-contrast': 'dark' };
export function getActiveTheme(): ThemeId {
const stored = load(THEME_STORAGE_KEY);
const saved = stored && RETIRED_THEMES[stored] ? RETIRED_THEMES[stored] : stored;
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) return saved;
if (saved && isCustomThemeId(saved) && load(CUSTOM_CSS_CACHE_KEY)) return saved;
return 'dark';
}
// customCss is required for custom themes and ignored for built-in ones
export function setActiveTheme(themeId: ThemeId, customCss?: string): void {
const css = isCustomThemeId(themeId) ? customCss ?? null : null;
const effectiveId = isCustomThemeId(themeId) && css === null ? 'dark' : themeId;
store(THEME_STORAGE_KEY, effectiveId);
store(CUSTOM_CSS_CACHE_KEY, css);
applyToDocument(effectiveId, css);
}
export function initThemeOnBoot(): ThemeId {
applyCorners(getCornerStyle());
applyButtonBorders(getButtonBorders());
const saved = getActiveTheme();
const active = resolveTheme(saved);
if (active !== saved) store(THEME_STORAGE_KEY, active);
applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null);
return active;
}