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:" 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; } 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: 'high-contrast', name: 'High Contrast (Scharf)', subtitle: 'Tiefschwarz (#000) & weiße Konturen', file: 'src/themes/theme-high-contrast.css', previewBg: '#000000', previewPanel: '#09090b', previewAccent: '#38bdf8', previewText: '#ffffff', isRounded: false, }, { id: 'soft', name: 'Soft Slate (Abgerundet)', subtitle: 'Schiefer-Blau mit weichen Radien (10px)', file: 'src/themes/theme-soft.css', previewBg: '#0f172a', previewPanel: '#1e293b', previewAccent: '#3b82f6', previewText: '#f8fafc', isRounded: true, }, ]; 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 , 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')) : themeId === 'light'; root.classList.toggle('dark', !light); syncThemeColorMeta(); } // The status bar (theme-color) starts blue for the splash screen; once the app runs it follows the // active theme's background so it blends with the header. 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; } } export function getActiveTheme(): ThemeId { const saved = load(THEME_STORAGE_KEY); 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 { const active = getActiveTheme(); applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null); return active; }