feat(themes): colour families with light/dark, follow system, corners, frames, accent
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s

- 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>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 23:29:52 +01:00
1 parent 2c9b4762fc
commit 3f0e596356
16 files changed
+949 -58

No files matched your search

+74 -1
View File
@@ -1,5 +1,19 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { setActiveTheme, initThemeOnBoot, customThemeOption, getActiveTheme } from '../theme';
import {
setActiveTheme,
initThemeOnBoot,
customThemeOption,
getActiveTheme,
themeCounterpart,
setCornerStyle,
getCornerStyle,
THEME_FAMILIES,
AVAILABLE_THEMES,
setFollowSystem,
resolveTheme,
setAccentColor,
setButtonBorders,
} from '../theme';
const lightCss = `:root[data-theme="papier"] { --bg-app: #fafafa; --accent-primary: #15803d; --radius-card: 8px; }`;
@@ -83,3 +97,62 @@ describe('built-in themes', () => {
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
describe('theme families and corners', () => {
beforeEach(() => localStorage.clear());
it('pairs every family with an existing light and dark theme', () => {
for (const f of THEME_FAMILIES) {
expect(AVAILABLE_THEMES.find((t) => t.id === f.light)?.mode).toBe('light');
expect(AVAILABLE_THEMES.find((t) => t.id === f.dark)?.mode).toBe('dark');
}
expect(themeCounterpart('fjord')).toBe('fjord-night');
expect(themeCounterpart('polarized')).toBe('polarized-light');
expect(themeCounterpart('custom:x')).toBeNull();
});
it('forces round or square corners and keeps the choice', () => {
setCornerStyle('square');
expect(document.documentElement.getAttribute('data-corners')).toBe('square');
expect(getCornerStyle()).toBe('square');
setCornerStyle('theme');
expect(document.documentElement.hasAttribute('data-corners')).toBe(false);
expect(getCornerStyle()).toBe('theme');
});
});
describe('follow system, accent and button frames', () => {
beforeEach(() => localStorage.clear());
it('picks the family variant that matches the system when following it', () => {
const original = window.matchMedia;
window.matchMedia = ((q: string) => ({ matches: q.includes('dark'), media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
try {
expect(resolveTheme('fjord')).toBe('fjord');
setFollowSystem(true);
expect(resolveTheme('fjord')).toBe('fjord-night');
expect(resolveTheme('suri-light')).toBe('suri');
expect(resolveTheme('custom:x')).toBe('custom:x');
} finally {
window.matchMedia = original;
}
});
it('sets the accent variables and removes them again', () => {
setActiveTheme('dark');
setAccentColor('#c2410c');
const style = document.documentElement.style;
expect(style.getPropertyValue('--accent-primary')).toBe('#c2410c');
expect(style.getPropertyValue('--text-on-accent')).toBe('#ffffff');
expect(style.getPropertyValue('--accent-dim')).toBe('rgba(194, 65, 12, 0.16)');
setAccentColor(null);
expect(style.getPropertyValue('--accent-primary')).toBe('');
});
it('switches button frames off and on', () => {
setButtonBorders('off');
expect(document.documentElement.getAttribute('data-button-borders')).toBe('off');
setButtonBorders('on');
expect(document.documentElement.hasAttribute('data-button-borders')).toBe(false);
});
});
+196 -2
View File
@@ -15,8 +15,28 @@ export interface ThemeOption {
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';
@@ -90,7 +110,176 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
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;
@@ -157,6 +346,7 @@ function applyToDocument(themeId: ThemeId, customCss: string | null): void {
? isLightColor(readCssVar(customCss, 'bg-app'))
: isLightColor(AVAILABLE_THEMES.find((t) => t.id === themeId)?.previewBg);
root.classList.toggle('dark', !light);
applyAccent(getAccentColor());
syncThemeColorMeta();
}
@@ -209,7 +399,11 @@ export function setActiveTheme(themeId: ThemeId, customCss?: string): void {
}
export function initThemeOnBoot(): ThemeId {
const active = getActiveTheme();
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;
}