MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m44s
A visitor without a chosen theme on this device sees the demo in fjord-night instead of the square default; a chosen theme stays and real installations are unchanged. Release v0.27.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
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);
|
||
syncThemeColorMeta(); // keeps the loading screen's colours current
|
||
}
|
||
|
||
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.
|
||
// The colours are also kept for the next start: public/boot-theme.js paints the loading screen in them.
|
||
function syncThemeColorMeta(): void {
|
||
const meta = document.querySelector('meta[name="theme-color"]');
|
||
if (!meta || typeof requestAnimationFrame === 'undefined') return;
|
||
requestAnimationFrame(() => {
|
||
const css = getComputedStyle(document.documentElement);
|
||
const bg = css.getPropertyValue('--bg-app').trim();
|
||
if (bg) meta.setAttribute('content', bg);
|
||
const accent = css.getPropertyValue('--accent-primary').trim();
|
||
const onAccent = css.getPropertyValue('--text-on-accent').trim();
|
||
if (bg && accent) store(BOOT_COLORS_KEY, JSON.stringify({ bg, accent, onAccent }));
|
||
});
|
||
}
|
||
|
||
const BOOT_COLORS_KEY = 'mindsift_boot_colors';
|
||
|
||
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';
|
||
}
|
||
|
||
/** True once a theme was chosen on this device (the default 'dark' is not stored until then) */
|
||
export function hasChosenTheme(): boolean {
|
||
return load(THEME_STORAGE_KEY) !== null;
|
||
}
|
||
|
||
// The demo starts with a round theme, so visitors see the rounded look; a chosen theme stays
|
||
export const DEMO_THEME: ThemeId = 'fjord-night';
|
||
|
||
// 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;
|
||
}
|