feat(matrix-web): add theme mode selector in settings

Replace placeholder appearance section with actual theme controls:
- Light mode button with sun icon
- Dark mode button with moon icon
- System mode button (follows OS preference)
- Shows current active mode status

Uses shared-theme store for consistent theming across the app.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 18:50:27 +01:00
parent 5777c76c47
commit b097d89318
5 changed files with 143 additions and 150 deletions

View file

@ -17,6 +17,9 @@
BellRinging,
SpeakerHigh,
Eye,
Sun,
Moon,
Desktop,
} from '@manacore/shared-icons';
import { VerificationDialog, RecoveryKeyDialog } from '$lib/components/crypto';
import {
@ -27,6 +30,7 @@
isNotificationSupported,
} from '$lib/notifications';
import { browser } from '$app/environment';
import { theme } from '$lib/stores/theme';
let verificationDialogOpen = $state(false);
let recoveryDialogOpen = $state(false);
@ -208,14 +212,62 @@
</div>
</section>
<!-- Appearance Section (Placeholder) -->
<!-- Appearance Section -->
<section class="card">
<div class="space-y-2">
<div class="space-y-4">
<h2 class="flex items-center gap-2 text-lg font-semibold">
<Palette class="h-5 w-5" />
Erscheinungsbild
</h2>
<p class="text-sm text-muted-foreground">Theme-Einstellungen folgen bald...</p>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">Wähle dein bevorzugtes Farbschema</p>
<!-- Theme Mode Selection -->
<div class="grid grid-cols-3 gap-2">
<button
class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all
{theme.mode === 'light'
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('light')}
>
<Sun class="h-6 w-6" />
<span class="text-sm font-medium">Hell</span>
</button>
<button
class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all
{theme.mode === 'dark'
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('dark')}
>
<Moon class="h-6 w-6" />
<span class="text-sm font-medium">Dunkel</span>
</button>
<button
class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all
{theme.mode === 'system'
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('system')}
>
<Desktop class="h-6 w-6" />
<span class="text-sm font-medium">System</span>
</button>
</div>
<!-- Current Status -->
<div class="flex items-center gap-2 text-sm text-muted-foreground">
{#if theme.mode === 'system'}
<span>Aktuell: {theme.isDark ? 'Dunkel' : 'Hell'} (basierend auf System)</span>
{:else}
<span>Aktuell: {theme.isDark ? 'Dunkel' : 'Hell'}</span>
{/if}
</div>
</div>
</div>
</section>

View file

@ -1,61 +1,47 @@
import { browser } from '$app/environment';
/**
* Theme Store - Manages theme state
* Uses shared theme store from @manacore/shared-theme
*/
type Theme = 'light' | 'dark' | 'system';
import { createThemeStore, type ThemeMode } from '@manacore/shared-theme';
function getInitialTheme(): Theme {
if (!browser) return 'system';
const stored = localStorage.getItem('theme') as Theme | null;
if (stored && ['light', 'dark', 'system'].includes(stored)) {
return stored;
}
return 'system';
}
function applyTheme(theme: Theme) {
if (!browser) return;
const root = document.documentElement;
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = theme === 'dark' || (theme === 'system' && systemDark);
if (isDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}
let currentTheme: Theme = 'system';
const sharedTheme = createThemeStore({ appId: 'questions' });
// Wrapper to maintain backward-compatible API
export const theme = {
// Legacy API (current → mode)
get current() {
return currentTheme;
return sharedTheme.mode;
},
initialize() {
currentTheme = getInitialTheme();
applyTheme(currentTheme);
if (browser) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (currentTheme === 'system') {
applyTheme('system');
}
});
}
// Forward all other getters
get mode() {
return sharedTheme.mode;
},
get isDark() {
return sharedTheme.isDark;
},
get variant() {
return sharedTheme.variant;
},
get variants() {
return sharedTheme.variants;
},
set(newTheme: Theme) {
currentTheme = newTheme;
if (browser) {
localStorage.setItem('theme', newTheme);
}
applyTheme(newTheme);
// Legacy API (set → setMode)
set(newTheme: ThemeMode) {
sharedTheme.setMode(newTheme);
},
// Legacy API (toggle → toggleMode)
toggle() {
const next = currentTheme === 'light' ? 'dark' : 'light';
this.set(next);
sharedTheme.toggleMode();
},
// Forward new API
initialize: sharedTheme.initialize,
setMode: sharedTheme.setMode,
setVariant: sharedTheme.setVariant,
toggleMode: sharedTheme.toggleMode,
cycleMode: sharedTheme.cycleMode,
};

View file

@ -1,95 +1,8 @@
/**
* Theme Store - Manages theme state
* Uses shared theme store from @manacore/shared-theme
*/
import { browser } from '$app/environment';
import {
THEME_DEFINITIONS,
THEME_VARIANTS,
type ThemeMode,
type ThemeVariant,
DEFAULT_VARIANT,
} from '@manacore/shared-theme';
import { createThemeStore } from '@manacore/shared-theme';
const STORAGE_KEY_MODE = 'storage-theme-mode';
const STORAGE_KEY_VARIANT = 'storage-theme-variant';
function createThemeStore() {
let mode = $state<ThemeMode>('system');
let variant = $state<ThemeVariant>(DEFAULT_VARIANT);
let systemPrefersDark = $state(false);
function getSystemPreference(): boolean {
if (!browser) return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function applyTheme() {
if (!browser) return;
const isDarkMode = mode === 'dark' || (mode === 'system' && systemPrefersDark);
const themeClass = isDarkMode
? THEME_DEFINITIONS[variant].darkClass
: THEME_DEFINITIONS[variant].lightClass;
document.documentElement.className = themeClass;
}
return {
get mode() {
return mode;
},
get variant() {
return variant;
},
get isDark() {
return mode === 'dark' || (mode === 'system' && systemPrefersDark);
},
get variants() {
return THEME_VARIANTS;
},
initialize() {
if (!browser) return;
const savedMode = localStorage.getItem(STORAGE_KEY_MODE) as ThemeMode | null;
const savedVariant = localStorage.getItem(STORAGE_KEY_VARIANT) as ThemeVariant | null;
if (savedMode) mode = savedMode;
if (savedVariant && savedVariant in THEME_DEFINITIONS) variant = savedVariant;
systemPrefersDark = getSystemPreference();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
systemPrefersDark = e.matches;
applyTheme();
});
applyTheme();
},
setMode(newMode: ThemeMode) {
mode = newMode;
if (browser) {
localStorage.setItem(STORAGE_KEY_MODE, newMode);
}
applyTheme();
},
setVariant(newVariant: ThemeVariant) {
variant = newVariant;
if (browser) {
localStorage.setItem(STORAGE_KEY_VARIANT, newVariant);
}
applyTheme();
},
toggleMode() {
const newMode = mode === 'dark' ? 'light' : 'dark';
this.setMode(newMode);
},
};
}
export const theme = createThemeStore();
export const theme = createThemeStore({ appId: 'storage' });

View file

@ -27,7 +27,8 @@
>
<div
class="theme-preview"
style="background: linear-gradient(135deg, {def.colors.primary}, {def.colors.accent})"
style="background: linear-gradient(135deg, hsl({def.light.primary}), hsl({def.light
.secondary}))"
>
{#if theme.variant === variant}
<div class="check-badge">

View file

@ -235,13 +235,30 @@ export const { isSidebarMode, isNavCollapsed } = createSimpleNavigationStores({
---
### 2.3 NIEDRIG: Theme Stores Migration
### ~~2.3 NIEDRIG: Theme Stores Migration~~ ✅ ERLEDIGT (~150 LOC gespart)
**Problem:** 2 Apps nutzen nicht `@manacore/shared-theme`:
- `apps/storage/apps/web/src/lib/stores/theme.svelte.ts` (96 LOC - custom)
- `apps/questions/apps/web/src/lib/stores/theme.ts` (custom)
**Status:** 2 Apps zu `createThemeStore()` aus `@manacore/shared-theme` migriert (29.01.2026)
**Aktion:** Migriere zu `createThemeStore()` aus `@manacore/shared-theme`
**Migrierte Apps:**
- ✅ `apps/storage/apps/web/src/lib/stores/theme.svelte.ts` (96 → 7 LOC = 89 LOC)
- ✅ `apps/questions/apps/web/src/lib/stores/theme.ts` (62 → 47 LOC = 15 LOC, mit Rückwärtskompatibilität)
**Zusätzlich gefixt:**
- ✅ `apps/storage/apps/web/src/routes/themes/+page.svelte` - Bug mit `def.colors.primary``def.light.primary`
**Questions Wrapper (Rückwärtskompatibilität):**
```typescript
// Legacy API (current, set, toggle) wird auf neue API gemappt
export const theme = {
get current() { return sharedTheme.mode; }, // Legacy
get mode() { return sharedTheme.mode; }, // New
set(newTheme) { sharedTheme.setMode(newTheme); }, // Legacy
toggle() { sharedTheme.toggleMode(); }, // Legacy
// ... new API forwarded
};
```
**Einsparung:** ~104 LOC (158 → 54 LOC)
---
@ -290,13 +307,37 @@ export const { isSidebarMode, isNavCollapsed } = createSimpleNavigationStores({
---
### 3.3 MITTEL: AppSlider Cleanup (240 LOC)
### ~~3.3 MITTEL: AppSlider Cleanup~~ ✅ ANALYSIERT (Keine Aktion nötig)
**Problem:** 8 Apps haben lokale `AppSlider.svelte` Kopien, obwohl shared-ui Version existiert.
**Status:** Nach Analyse: Die lokalen AppSlider.svelte Dateien sind KEINE Duplikate (29.01.2026)
**Betroffene Apps:** calendar, chat, contacts, manadeck, manacore, picture, presi, todo
**Ergebnis der Analyse:**
Die 8 lokalen `AppSlider.svelte` Dateien sind **Lokalisierungs-Wrapper**, nicht Duplikate:
- Sie importieren `AppSlider` aus `@manacore/shared-ui`
- Sie mappen `MANA_APPS` aus `@manacore/shared-branding` zu deutschen Labels
- Sie übergeben deutsche Lokalisierung (`APP_STATUS_LABELS.de`, `APP_SLIDER_LABELS.de`) an die shared Komponente
**Aktion:** Verifiziere Import aus `@manacore/shared-ui`, lösche lokale Kopien.
**Beispiel (apps/chat/apps/web):**
```svelte
<script lang="ts">
import { AppSlider } from '@manacore/shared-ui';
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
const apps = MANA_APPS.map((app) => ({
name: app.name,
description: app.description.de, // German localization
longDescription: app.longDescription.de,
// ...
}));
const statusLabels = APP_STATUS_LABELS.de;
const labels = APP_SLIDER_LABELS.de;
</script>
<AppSlider {apps} title={labels.title} {statusLabels} ... />
```
**Fazit:** Korrekte Architektur - shared-ui stellt die Komponente bereit, Apps liefern lokalisierte Daten.
---