feat: implement unified theme system across all web apps

SUMMARY:
Create a unified theming architecture with two new shared packages
(@manacore/shared-theme and @manacore/shared-theme-ui) that provides
consistent theming across all 4 web applications while allowing
app-specific primary color customization.

NEW PACKAGES:

@manacore/shared-theme:
- Svelte 5 Runes-based theme store factory
- 4 theme variants: Lume (Gold), Nature (Green), Stone (Blue Gray), Ocean (Blue)
- 3 theme modes: Light, Dark, System (auto-detect)
- HSL-based color system with 18 semantic tokens
- localStorage persistence per app
- System preference detection via matchMedia
- Pre-defined configs for all apps (memoro, manacore, manadeck, maerchenzauber)

@manacore/shared-theme-ui:
- ThemeToggle: Light/dark mode toggle button
- ThemeSelector: Visual theme variant selector with color dots
- ThemeModeSelector: Segmented control for light/dark/system

UPDATED PACKAGES:

@manacore/shared-tailwind:
- Converted from HEX to HSL-based CSS variables
- Updated preset.js with hsl(var(--color-*)) syntax
- themes.css now contains all 4 theme variants with light/dark modes

APP MIGRATIONS:

memoro/web:
- Replaced 145 LOC theme store with 25 LOC shared implementation
- Deleted local ThemeSelector.svelte and ThemeToggle.svelte
- Primary color: Gold (47 95% 58%)

manacore/web:
- Replaced 80 LOC theme store with 25 LOC shared implementation
- Deleted local ThemeToggle.svelte
- Primary color: Indigo (239 84% 67%)

manadeck/web:
- Added new theme store using shared package
- Primary color: Indigo (239 84% 67%)

maerchenzauber/web:
- Added new theme store using shared package
- Primary color: Purple (280 60% 55%)

All app layouts updated with theme.initialize() in onMount.

BENEFITS:
- ~225 LOC of app-specific code reduced to ~100 LOC total
- Single source of truth for theme logic
- Consistent theming experience across all apps
- Easy to add new theme variants
- App-specific primary colors preserved

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-24 21:51:24 +01:00
parent ef70a1af0b
commit 96e0aceb93
31 changed files with 2993 additions and 1089 deletions

View file

@ -1,233 +0,0 @@
<script lang="ts">
import { theme, type ThemeVariant } from '$lib/stores/theme';
// Theme info with icons and names
const themes: Record<ThemeVariant, { icon: string; name: string; code: string }> = {
lume: { icon: '✨', name: 'Lume', code: 'LU' },
nature: { icon: '🌿', name: 'Nature', code: 'NA' },
stone: { icon: '🪨', name: 'Stone', code: 'ST' },
ocean: { icon: '🌊', name: 'Ocean', code: 'OC' }
};
const themeVariants: ThemeVariant[] = ['lume', 'nature', 'stone', 'ocean'];
let currentTheme = $derived($theme);
let isDark = $derived(currentTheme.effectiveMode === 'dark');
let currentVariant = $derived(currentTheme.variant);
let isOpen = $state(false);
function getPrimaryColor() {
const variant = currentTheme.variant;
if (isDark) {
const colors = {
lume: '#f8d62b',
nature: '#4CAF50',
stone: '#78909C',
ocean: '#039BE5'
};
return colors[variant];
} else {
const colors = {
lume: '#f8d62b',
nature: '#4CAF50',
stone: '#607D8B',
ocean: '#039BE5'
};
return colors[variant];
}
}
function handleThemeChange(newVariant: ThemeVariant) {
theme.setVariant(newVariant);
isOpen = false;
}
function toggleDropdown() {
isOpen = !isOpen;
}
// Close dropdown when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.theme-selector')) {
isOpen = false;
}
}
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script>
<div class="theme-selector">
<button
onclick={toggleDropdown}
class="theme-button"
class:active={isOpen}
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.6)'};
border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'};
color: {isDark ? '#ffffff' : '#000000'};"
>
<span class="icon">{themes[currentVariant].icon}</span>
<span class="name">{themes[currentVariant].name}</span>
<svg
class="chevron"
class:rotate={isOpen}
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
>
<path d="M2 4l4 4 4-4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
{#if isOpen}
<div
class="dropdown"
style="background-color: {isDark ? '#1E1E1E' : '#ffffff'};
border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'};
box-shadow: 0 4px 12px rgba(0, 0, 0, {isDark ? '0.4' : '0.15'});"
>
{#each themeVariants as variant}
<button
onclick={() => handleThemeChange(variant)}
class="dropdown-item"
class:active={currentVariant === variant}
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<span class="icon">{themes[variant].icon}</span>
<span class="name">{themes[variant].name}</span>
{#if currentVariant === variant}
<svg
class="check"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke={getPrimaryColor()}
>
<path
d="M3 8l3 3 7-7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.theme-selector {
position: relative;
display: inline-block;
}
.theme-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border: 1px solid;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.theme-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.theme-button.active {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.icon {
font-size: 1.125rem;
line-height: 1;
}
.theme-button .name {
font-weight: 600;
letter-spacing: 0.025em;
}
.chevron {
transition: transform 0.2s;
margin-left: 0.25rem;
}
.chevron.rotate {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 160px;
border-radius: 0.75rem;
border: 1px solid;
overflow: hidden;
z-index: 100;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: none;
cursor: pointer;
transition: background-color 0.15s;
font-size: 0.875rem;
}
.dropdown-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
:global(.dark) .dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.dropdown-item .name {
flex: 1;
text-align: left;
font-weight: 500;
}
.dropdown-item.active {
font-weight: 600;
}
.check {
flex-shrink: 0;
}
</style>

View file

@ -1,40 +0,0 @@
<script lang="ts">
import { theme } from '$lib/stores/theme';
let currentTheme = $derived($theme);
function toggleTheme() {
theme.toggleMode();
}
</script>
<button
onclick={toggleTheme}
class="rounded-lg p-2 transition-colors bg-menu-hover"
aria-label="Toggle theme"
title={currentTheme.effectiveMode === 'light'
? 'Switch to dark mode'
: 'Switch to light mode'}
>
{#if currentTheme.effectiveMode === 'light'}
<!-- Moon Icon (Dark Mode) -->
<svg class="h-5 w-5 text-theme" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
{:else}
<!-- Sun Icon (Light Mode) -->
<svg class="h-5 w-5 text-theme" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{/if}
</button>

View file

@ -1,144 +1,25 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
/**
* Memoro Theme Store
*
* Uses the shared theme system with Memoro's gold primary color.
*/
import { createThemeStore } from '@manacore/shared-theme';
export type ThemeMode = 'light' | 'dark' | 'system';
export type ThemeVariant = 'lume' | 'nature' | 'ocean' | 'stone';
// Re-export types for convenience
export type { ThemeMode, ThemeVariant, EffectiveMode } from '@manacore/shared-theme';
interface ThemeState {
mode: ThemeMode;
variant: ThemeVariant;
// The actual rendered mode (light or dark), derived from mode and system preference
effectiveMode: 'light' | 'dark';
}
const THEME_STORAGE_KEY = 'memoro-theme';
// Get initial theme from localStorage or system preference
function getInitialTheme(): ThemeState {
if (!browser) {
return { mode: 'system', variant: 'lume', effectiveMode: 'light' };
}
const stored = localStorage.getItem(THEME_STORAGE_KEY);
let mode: ThemeMode = 'system';
let variant: ThemeVariant = 'lume';
if (stored) {
try {
const parsed = JSON.parse(stored);
mode = parsed.mode || 'system';
variant = parsed.variant || 'lume';
} catch {
// Fall through to default
}
}
// Calculate effective mode
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const effectiveMode: 'light' | 'dark' =
mode === 'system' ? (prefersDark ? 'dark' : 'light') : mode === 'dark' ? 'dark' : 'light';
return {
mode,
variant,
effectiveMode
};
}
// Create the theme store
function createThemeStore() {
const { subscribe, set, update } = writable<ThemeState>(getInitialTheme());
return {
subscribe,
setMode: (mode: ThemeMode) => {
update((state) => {
const prefersDark = browser
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
const effectiveMode: 'light' | 'dark' =
mode === 'system'
? prefersDark
? 'dark'
: 'light'
: mode === 'dark'
? 'dark'
: 'light';
const newState = { ...state, mode, effectiveMode };
if (browser) {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(newState));
applyTheme(newState);
}
return newState;
});
},
setVariant: (variant: ThemeVariant) => {
update((state) => {
const newState = { ...state, variant };
if (browser) {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(newState));
applyTheme(newState);
}
return newState;
});
},
toggleMode: () => {
update((state) => {
// Toggle between light and dark (skip system)
const newMode: ThemeMode = state.effectiveMode === 'light' ? 'dark' : 'light';
const newState = { ...state, mode: newMode, effectiveMode: newMode };
if (browser) {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(newState));
applyTheme(newState);
}
return newState;
});
},
initialize: () => {
if (browser) {
const state = getInitialTheme();
set(state);
applyTheme(state);
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
// Only update if mode is set to 'system'
update((state) => {
if (state.mode === 'system') {
const newEffectiveMode: 'light' | 'dark' = e.matches ? 'dark' : 'light';
const newState = { ...state, effectiveMode: newEffectiveMode };
applyTheme(newState);
return newState;
}
return state;
});
};
mediaQuery.addEventListener('change', handleChange);
// Cleanup function
return () => mediaQuery.removeEventListener('change', handleChange);
}
}
};
}
// Apply theme to document
function applyTheme(state: ThemeState) {
if (!browser) return;
const html = document.documentElement;
// Apply dark mode class based on effectiveMode
if (state.effectiveMode === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
// Apply theme variant as data attribute
html.setAttribute('data-theme', state.variant);
}
export const theme = createThemeStore();
/**
* Memoro theme store instance
*
* - Default variant: lume (gold)
* - Custom primary: Gold (#f8d62b)
* - All 4 theme variants available
*/
export const theme = createThemeStore({
appId: 'memoro',
defaultVariant: 'lume',
primaryColor: {
light: '47 95% 58%', // Gold #f8d62b
dark: '47 95% 58%',
},
});

View file

@ -9,45 +9,11 @@
let { children } = $props();
let currentTheme = $derived($theme);
let isDark = $derived(currentTheme.effectiveMode === 'dark');
// Get page background based on theme variant
let pageBackground = $derived(() => {
const variant = currentTheme.variant;
if (isDark) {
const colors: Record<string, string> = {
lume: '#101010',
nature: '#121212',
stone: '#121212',
ocean: '#121212'
};
return colors[variant];
} else {
const colors: Record<string, string> = {
lume: '#dddddd',
nature: '#FBFDF8',
stone: '#F5F7F9',
ocean: '#F5FCFF'
};
return colors[variant];
}
});
// Initialize theme on mount
onMount(() => {
const cleanup = theme.initialize();
return cleanup;
});
// Update body and html background when theme changes
$effect(() => {
if (typeof document !== 'undefined') {
const bgColor = pageBackground();
document.documentElement.style.backgroundColor = bgColor;
document.body.style.backgroundColor = bgColor;
}
});
</script>
{@render children?.()}