feat(mvp): phase 7 - UI polish & ambient backgrounds

Add layout system (sidebar, header, main layout), dark/light/system theme
with HSL customization, 3 ambient backgrounds (mesh gradient, particle field,
aurora), Cmd/Ctrl+K search dialog, page transitions, card hover effects,
status pulse animations, skeleton loaders, and responsive design. Polish
all existing pages with consistent theming.
This commit is contained in:
2026-03-24 21:37:16 +03:00
parent c5166ba3a9
commit 0bd30c5e17
41 changed files with 2106 additions and 391 deletions
+120
View File
@@ -0,0 +1,120 @@
const THEME_STORAGE_KEY = 'wal-theme-mode';
const PRIMARY_HUE_KEY = 'wal-primary-hue';
const PRIMARY_SAT_KEY = 'wal-primary-sat';
const BG_TYPE_KEY = 'wal-bg-type';
export type ThemeMode = 'dark' | 'light' | 'system';
export type BackgroundType = 'mesh' | 'particles' | 'aurora' | 'none';
function getStoredValue<T>(key: string, fallback: T): T {
if (typeof window === 'undefined') return fallback;
try {
const stored = localStorage.getItem(key);
if (stored === null) return fallback;
return stored as unknown as T;
} catch {
return fallback;
}
}
function getStoredNumber(key: string, fallback: number): number {
if (typeof window === 'undefined') return fallback;
try {
const stored = localStorage.getItem(key);
if (stored === null) return fallback;
const parsed = Number(stored);
return Number.isNaN(parsed) ? fallback : parsed;
} catch {
return fallback;
}
}
class ThemeStore {
mode = $state<ThemeMode>('system');
primaryHue = $state(220);
primarySaturation = $state(70);
backgroundType = $state<BackgroundType>('mesh');
resolvedMode = $derived<'dark' | 'light'>(
this.mode === 'system' ? this.#systemPreference : this.mode
);
isDark = $derived(this.resolvedMode === 'dark');
#systemPreference: 'dark' | 'light' = 'dark';
constructor() {
if (typeof window !== 'undefined') {
this.mode = getStoredValue<ThemeMode>(THEME_STORAGE_KEY, 'system');
this.primaryHue = getStoredNumber(PRIMARY_HUE_KEY, 220);
this.primarySaturation = getStoredNumber(PRIMARY_SAT_KEY, 70);
this.backgroundType = getStoredValue<BackgroundType>(BG_TYPE_KEY, 'mesh');
const mql = window.matchMedia('(prefers-color-scheme: dark)');
this.#systemPreference = mql.matches ? 'dark' : 'light';
mql.addEventListener('change', (e) => {
this.#systemPreference = e.matches ? 'dark' : 'light';
});
}
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(THEME_STORAGE_KEY, this.mode);
});
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(PRIMARY_HUE_KEY, String(this.primaryHue));
});
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(PRIMARY_SAT_KEY, String(this.primarySaturation));
});
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(BG_TYPE_KEY, this.backgroundType);
});
$effect(() => {
if (typeof document === 'undefined') return;
const html = document.documentElement;
if (this.resolvedMode === 'dark') {
html.classList.add('dark');
html.classList.remove('light');
} else {
html.classList.remove('dark');
html.classList.add('light');
}
});
$effect(() => {
if (typeof document === 'undefined') return;
const html = document.documentElement;
html.style.setProperty('--primary-h', String(this.primaryHue));
html.style.setProperty('--primary-s', `${this.primarySaturation}%`);
});
}
cycleMode() {
const modes: ThemeMode[] = ['light', 'dark', 'system'];
const idx = modes.indexOf(this.mode);
this.mode = modes[(idx + 1) % modes.length];
}
setMode(mode: ThemeMode) {
this.mode = mode;
}
setBackground(bg: BackgroundType) {
this.backgroundType = bg;
}
setPrimaryColor(hue: number, saturation: number) {
this.primaryHue = Math.max(0, Math.min(360, hue));
this.primarySaturation = Math.max(0, Math.min(100, saturation));
}
}
export const theme = new ThemeStore();