mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
ef70a1af0b
commit
96e0aceb93
31 changed files with 2993 additions and 1089 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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%',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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?.()}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue