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
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:
1 parent
2c9b4762fc
commit
3f0e596356
16 files changed
+949
-58
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user