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

@ -8,7 +8,9 @@ This document outlines the plan to unify common code across all web apps in the
- [x] `@manacore/shared-ui` - Unified UI Components (Text, Button, Badge, Toggle, Input, Modal) - [x] `@manacore/shared-ui` - Unified UI Components (Text, Button, Badge, Toggle, Input, Modal)
- [x] `@manacore/shared-auth` - Unified Auth Logic (Supabase client, token management) - [x] `@manacore/shared-auth` - Unified Auth Logic (Supabase client, token management)
- [x] `@manacore/shared-auth-ui` - Unified Auth UI (LoginPage, RegisterPage, OAuth buttons) - [x] `@manacore/shared-auth-ui` - Unified Auth UI (LoginPage, RegisterPage, OAuth buttons)
- [x] `@manacore/shared-tailwind` - Unified Tailwind Config (4 themes, colors, preset) - [x] `@manacore/shared-tailwind` - Unified Tailwind Config (HSL colors, preset, themes.css)
- [x] `@manacore/shared-theme` - **NEW** Unified Theme Store (Svelte 5, 4 variants, light/dark/system)
- [x] `@manacore/shared-theme-ui` - **NEW** Theme UI Components (ThemeToggle, ThemeSelector)
- [x] `@manacore/shared-utils` - Unified Utilities (formatting, validation, async) - [x] `@manacore/shared-utils` - Unified Utilities (formatting, validation, async)
- [x] `@manacore/shared-types` - Unified TypeScript Types - [x] `@manacore/shared-types` - Unified TypeScript Types
- [x] `@manacore/shared-supabase` - Unified Supabase Client Factory - [x] `@manacore/shared-supabase` - Unified Supabase Client Factory

View file

@ -0,0 +1,25 @@
/**
* Maerchenzauber (Storyteller) Theme Store
*
* Uses the shared theme system with a magical purple primary color.
*/
import { createThemeStore } from '@manacore/shared-theme';
// Re-export types for convenience
export type { ThemeMode, ThemeVariant, EffectiveMode } from '@manacore/shared-theme';
/**
* Maerchenzauber theme store instance
*
* - Default variant: nature (green - enchanted forest feel)
* - Custom primary: Purple (magical storytelling)
* - All 4 theme variants available
*/
export const theme = createThemeStore({
appId: 'maerchenzauber',
defaultVariant: 'nature',
primaryColor: {
light: '280 60% 55%', // Purple - magical/storytelling
dark: '280 60% 60%',
},
});

View file

@ -1,8 +1,15 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
let { children } = $props(); let { children } = $props();
onMount(() => {
const cleanup = theme.initialize();
return cleanup;
});
</script> </script>
<svelte:head> <svelte:head>

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 hover:bg-gray-200 dark:hover:bg-gray-700"
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-gray-600" 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-gray-300" 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,79 +1,25 @@
import { writable, derived } from 'svelte/store'; /**
import { browser } from '$app/environment'; * ManaCore Theme Store
*
* Uses the shared theme system with ManaCore's indigo primary color.
*/
import { createThemeStore } from '@manacore/shared-theme';
type ThemeMode = 'light' | 'dark' | 'system'; // Re-export types for convenience
export type { ThemeMode, ThemeVariant, EffectiveMode } from '@manacore/shared-theme';
interface ThemeState { /**
mode: ThemeMode; * ManaCore theme store instance
effectiveMode: 'light' | 'dark'; *
} * - Default variant: ocean (blue)
* - Custom primary: Indigo (#6366f1)
function createThemeStore() { * - All 4 theme variants available
const getInitialMode = (): ThemeMode => { */
if (browser) { export const theme = createThemeStore({
const stored = localStorage.getItem('theme-mode'); appId: 'manacore',
if (stored === 'light' || stored === 'dark' || stored === 'system') { defaultVariant: 'ocean',
return stored; primaryColor: {
} light: '239 84% 67%', // Indigo #6366f1
} dark: '239 84% 67%',
return 'system'; },
}; });
const getSystemPreference = (): 'light' | 'dark' => {
if (browser && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
};
const mode = writable<ThemeMode>(getInitialMode());
const effectiveMode = derived(mode, ($mode) => {
if ($mode === 'system') {
return getSystemPreference();
}
return $mode;
});
const state = derived([mode, effectiveMode], ([$mode, $effectiveMode]) => ({
mode: $mode,
effectiveMode: $effectiveMode
}));
// Apply theme to document
if (browser) {
effectiveMode.subscribe((effective) => {
if (effective === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
});
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
mode.update((m) => m); // Trigger re-evaluation
});
}
return {
subscribe: state.subscribe,
setMode: (newMode: ThemeMode) => {
mode.set(newMode);
if (browser) {
localStorage.setItem('theme-mode', newMode);
}
},
toggleMode: () => {
mode.update((current) => {
const newMode = current === 'light' ? 'dark' : current === 'dark' ? 'system' : 'light';
if (browser) {
localStorage.setItem('theme-mode', newMode);
}
return newMode;
});
}
};
}
export const theme = createThemeStore();

View file

@ -2,10 +2,15 @@
import '../app.css'; import '../app.css';
import { invalidate } from '$app/navigation'; import { invalidate } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
let { data, children } = $props(); let { data, children } = $props();
onMount(() => { onMount(() => {
// Initialize theme
const cleanupTheme = theme.initialize();
// Setup auth state change listener
const { const {
data: { subscription } data: { subscription }
} = data.supabase.auth.onAuthStateChange(async (event, session) => { } = data.supabase.auth.onAuthStateChange(async (event, session) => {
@ -16,7 +21,10 @@
} }
}); });
return () => subscription.unsubscribe(); return () => {
cleanupTheme();
subscription.unsubscribe();
};
}); });
</script> </script>

View file

@ -0,0 +1,25 @@
/**
* ManaDeck Theme Store
*
* Uses the shared theme system with ManaDeck's indigo primary color.
*/
import { createThemeStore } from '@manacore/shared-theme';
// Re-export types for convenience
export type { ThemeMode, ThemeVariant, EffectiveMode } from '@manacore/shared-theme';
/**
* ManaDeck theme store instance
*
* - Default variant: ocean (blue)
* - Custom primary: Indigo (#6366f1)
* - All 4 theme variants available
*/
export const theme = createThemeStore({
appId: 'manadeck',
defaultVariant: 'ocean',
primaryColor: {
light: '239 84% 67%', // Indigo #6366f1
dark: '239 84% 67%',
},
});

View file

@ -1,8 +1,15 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
let { children } = $props(); let { children } = $props();
onMount(() => {
const cleanup = theme.initialize();
return cleanup;
});
</script> </script>
<svelte:head> <svelte:head>

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'; // Re-export types for convenience
export type ThemeVariant = 'lume' | 'nature' | 'ocean' | 'stone'; export type { ThemeMode, ThemeVariant, EffectiveMode } from '@manacore/shared-theme';
interface ThemeState { /**
mode: ThemeMode; * Memoro theme store instance
variant: ThemeVariant; *
// The actual rendered mode (light or dark), derived from mode and system preference * - Default variant: lume (gold)
effectiveMode: 'light' | 'dark'; * - Custom primary: Gold (#f8d62b)
} * - All 4 theme variants available
*/
const THEME_STORAGE_KEY = 'memoro-theme'; export const theme = createThemeStore({
appId: 'memoro',
// Get initial theme from localStorage or system preference defaultVariant: 'lume',
function getInitialTheme(): ThemeState { primaryColor: {
if (!browser) { light: '47 95% 58%', // Gold #f8d62b
return { mode: 'system', variant: 'lume', effectiveMode: 'light' }; dark: '47 95% 58%',
} },
});
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();

View file

@ -9,45 +9,11 @@
let { children } = $props(); 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 // Initialize theme on mount
onMount(() => { onMount(() => {
const cleanup = theme.initialize(); const cleanup = theme.initialize();
return cleanup; 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> </script>
{@render children?.()} {@render children?.()}

View file

@ -15,7 +15,10 @@
"maerchenzauber:dev": "turbo run dev --filter=maerchenzauber...", "maerchenzauber:dev": "turbo run dev --filter=maerchenzauber...",
"manacore:dev": "turbo run dev --filter=manacore...", "manacore:dev": "turbo run dev --filter=manacore...",
"manadeck:dev": "turbo run dev --filter=manadeck...", "manadeck:dev": "turbo run dev --filter=manadeck...",
"memoro:dev": "turbo run dev --filter=memoro..." "memoro:dev": "turbo run dev --filter=memoro...",
"dev:web": "turbo run dev --filter=@storyteller/web --filter=manacore-web --filter=web --filter=memoro-web",
"dev:landing": "turbo run dev --filter=@storyteller/landing --filter=manacore-landing --filter=landing --filter=memoro-landing",
"dev:mobile": "turbo run dev --filter=@storyteller/mobile --filter=manacore --filter=manadeck --filter=memoro"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.3.3", "prettier": "^3.3.3",

View file

@ -1,104 +1,111 @@
/** /**
* Shared Tailwind CSS preset for all ManaCore apps * Shared Tailwind CSS preset for all ManaCore apps
* *
* This preset uses HSL-based CSS variables for theming.
* Colors are defined as HSL values (e.g., "47 95% 58%") and
* wrapped with hsl() in the Tailwind config for flexibility.
*
* Usage in tailwind.config.js: * Usage in tailwind.config.js:
* ``` * ```
* import sharedPreset from '@manacore/shared-tailwind/preset'; * import preset from '@manacore/shared-tailwind/preset';
* *
* export default { * export default {
* presets: [sharedPreset], * presets: [preset],
* content: ['./src/**\/*.{html,js,svelte,ts}'], * content: ['./src/**\/*.{html,js,svelte,ts}'],
* // app-specific overrides... * // app-specific overrides...
* } * }
* ``` * ```
*/ */
import { colors } from './colors.js';
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
const preset = { const preset = {
darkMode: 'class', darkMode: 'class',
theme: { theme: {
extend: { extend: {
colors: { colors: {
// Brand colors // Brand color (consistent across all apps)
mana: colors.mana, mana: '#4287f5',
// Primary scale // ===== HSL-Based Semantic Colors =====
primary: colors.primary, // These use CSS variables set by @manacore/shared-theme
// Format: hsl(var(--color-name)) where --color-name is "H S% L%"
// Semantic colors using CSS custom properties // Page background
// These can be changed at runtime via themes.css background: 'hsl(var(--color-background))',
background: 'var(--color-background)',
foreground: 'var(--color-foreground)',
// Content areas // Main text color
content: { foreground: 'hsl(var(--color-foreground))',
DEFAULT: 'var(--color-content)',
hover: 'var(--color-content-hover)', // Primary brand color (customizable per app)
page: 'var(--color-content-page)', primary: {
DEFAULT: 'hsl(var(--color-primary))',
foreground: 'hsl(var(--color-primary-foreground))',
}, },
// Menu/sidebar // Secondary accent
menu: { secondary: {
DEFAULT: 'var(--color-menu)', DEFAULT: 'hsl(var(--color-secondary))',
hover: 'var(--color-menu-hover)', foreground: 'hsl(var(--color-secondary-foreground))',
}, },
// Text // Card/content surfaces
theme: { surface: {
DEFAULT: 'var(--color-text)', DEFAULT: 'hsl(var(--color-surface))',
secondary: 'var(--color-text-secondary)', hover: 'hsl(var(--color-surface-hover))',
elevated: 'hsl(var(--color-surface-elevated))',
},
// Muted/disabled elements
muted: {
DEFAULT: 'hsl(var(--color-muted))',
foreground: 'hsl(var(--color-muted-foreground))',
}, },
// Borders // Borders
border: { border: {
light: 'var(--color-border-light)', DEFAULT: 'hsl(var(--color-border))',
DEFAULT: 'var(--color-border)', strong: 'hsl(var(--color-border-strong))',
strong: 'var(--color-border-strong)',
}, },
// Buttons // Semantic/feedback colors
error: 'hsl(var(--color-error))',
success: 'hsl(var(--color-success))',
warning: 'hsl(var(--color-warning))',
// Form elements
input: 'hsl(var(--color-input))',
ring: 'hsl(var(--color-ring))',
// ===== Legacy aliases (for backwards compatibility) =====
content: {
DEFAULT: 'hsl(var(--color-surface))',
hover: 'hsl(var(--color-surface-hover))',
page: 'hsl(var(--color-background))',
},
menu: {
DEFAULT: 'hsl(var(--color-muted))',
hover: 'hsl(var(--color-surface-hover))',
},
theme: {
DEFAULT: 'hsl(var(--color-foreground))',
secondary: 'hsl(var(--color-muted-foreground))',
},
'primary-btn': { 'primary-btn': {
DEFAULT: 'var(--color-primary-button)', DEFAULT: 'hsl(var(--color-primary))',
text: 'var(--color-primary-button-text)', text: 'hsl(var(--color-primary-foreground))',
},
'secondary-btn': 'var(--color-secondary-button)',
// Feedback colors
error: 'var(--color-error)',
success: 'var(--color-success)',
warning: 'var(--color-warning)',
// Direct theme colors (for apps that don't use CSS vars)
lume: {
...colors.lume.light,
dark: colors.lume.dark,
},
nature: {
...colors.nature.light,
dark: colors.nature.dark,
},
stone: {
...colors.stone.light,
dark: colors.stone.dark,
},
ocean: {
...colors.ocean.light,
dark: colors.ocean.dark,
}, },
}, },
// Border radius tokens // Border radius tokens (CSS variable support)
borderRadius: { borderRadius: {
'none': '0', 'none': '0',
'sm': '0.25rem', 'sm': 'var(--radius-sm, 0.25rem)',
DEFAULT: '0.375rem', DEFAULT: 'var(--radius, 0.375rem)',
'md': '0.5rem', 'md': 'var(--radius-md, 0.5rem)',
'lg': '0.75rem', 'lg': 'var(--radius-lg, 0.75rem)',
'xl': '1rem', 'xl': 'var(--radius-xl, 1rem)',
'2xl': '1.5rem', '2xl': 'var(--radius-2xl, 1.5rem)',
'3xl': '2rem', '3xl': 'var(--radius-3xl, 2rem)',
'full': '9999px', 'full': '9999px',
}, },
@ -142,6 +149,29 @@ const preset = {
'spin-slow': 'spin 3s linear infinite', 'spin-slow': 'spin 3s linear infinite',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'bounce-slow': 'bounce 2s infinite', 'bounce-slow': 'bounce 2s infinite',
'fade-in': 'fadeIn 0.2s ease-out',
'fade-out': 'fadeOut 0.2s ease-in',
'slide-in': 'slideIn 0.2s ease-out',
'slide-out': 'slideOut 0.2s ease-in',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
fadeOut: {
'0%': { opacity: '1' },
'100%': { opacity: '0' },
},
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideOut: {
'0%': { transform: 'translateY(0)', opacity: '1' },
'100%': { transform: 'translateY(-10px)', opacity: '0' },
},
}, },
// Transition // Transition

View file

@ -1,265 +1,434 @@
/** /**
* CSS Custom Properties for ManaCore themes * Shared Theme CSS Variables (HSL-based)
* *
* Usage: * This file defines HSL-based CSS custom properties for all theme variants.
* 1. Import in your app.css: @import '@manacore/shared-tailwind/themes.css'; * Variables are set by @manacore/shared-theme's createThemeStore() at runtime,
* 2. Set theme with data-theme attribute: <html data-theme="lume"> * but this file provides sensible defaults for static rendering.
* 3. Dark mode is automatic with .dark class or prefers-color-scheme *
* Usage in app.css:
* ```css
* @import '@manacore/shared-tailwind/themes.css';
* @tailwind base;
* @tailwind components;
* @tailwind utilities;
* ```
*
* Color format: HSL values without hsl() wrapper
* Example: --color-primary: 47 95% 58%;
* Used as: hsl(var(--color-primary))
*/ */
/* Default: Lume Light Theme */ /* ===== Default Theme (Lume Light) ===== */
:root, :root {
[data-theme="lume"] { /* Primary brand color */
--color-primary: #f8d62b; --color-primary: 47 95% 58%;
--color-primary-button: #f8d62b; --color-primary-foreground: 0 0% 0%;
--color-primary-button-text: #000000;
--color-secondary: #D4B200;
--color-secondary-button: #FFE9A3;
--color-background: #dddddd; /* Secondary accent */
--color-foreground: #2c2c2c; --color-secondary: 47 100% 41%;
--color-content: #ffffff; --color-secondary-foreground: 0 0% 0%;
--color-content-hover: #f5f5f5;
--color-content-page: #ffffff;
--color-menu: #dddddd;
--color-menu-hover: #cccccc;
--color-text: #2c2c2c; /* Page background */
--color-text-secondary: #666666; --color-background: 0 0% 87%;
--color-border-light: #f2f2f2; /* Main text color */
--color-border: #e6e6e6; --color-foreground: 0 0% 17%;
--color-border-strong: #cccccc;
--color-error: #e74c3c; /* Surfaces (cards, modals, etc.) */
--color-success: #27ae60; --color-surface: 0 0% 100%;
--color-warning: #f39c12; --color-surface-hover: 0 0% 96%;
--color-surface-elevated: 0 0% 100%;
/* Muted/subtle elements */
--color-muted: 0 0% 90%;
--color-muted-foreground: 0 0% 40%;
/* Borders */
--color-border: 0 0% 90%;
--color-border-strong: 0 0% 80%;
/* Semantic colors */
--color-error: 6 78% 57%;
--color-success: 145 63% 42%;
--color-warning: 36 100% 50%;
/* Form elements */
--color-input: 0 0% 100%;
--color-ring: 47 95% 58%;
/* Border radius */
--radius-sm: 0.25rem;
--radius: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-3xl: 2rem;
} }
/* Lume Dark */ /* ===== Dark Mode ===== */
.dark, .dark,
[data-theme="lume"].dark, :root.dark {
[data-theme="lume"] .dark { --color-primary: 47 95% 58%;
--color-primary: #f8d62b; --color-primary-foreground: 0 0% 0%;
--color-primary-button: #7C6B16;
--color-primary-button-text: #ffffff;
--color-secondary: #D4B200;
--color-secondary-button: #1E1E1E;
--color-background: #101010; --color-secondary: 47 70% 29%;
--color-foreground: #ffffff; --color-secondary-foreground: 0 0% 100%;
--color-content: #1E1E1E;
--color-content-hover: #333333;
--color-content-page: #121212;
--color-menu: #101010;
--color-menu-hover: #333333;
--color-text: #ffffff; --color-background: 0 0% 6%;
--color-text-secondary: #a0a0a0; --color-foreground: 0 0% 100%;
--color-border-light: #333333; --color-surface: 0 0% 12%;
--color-border: #424242; --color-surface-hover: 0 0% 16%;
--color-border-strong: #616161; --color-surface-elevated: 0 0% 14%;
--color-error: #e74c3c; --color-muted: 0 0% 20%;
--color-success: #2ecc71; --color-muted-foreground: 0 0% 60%;
--color-warning: #f1c40f;
--color-border: 0 0% 26%;
--color-border-strong: 0 0% 35%;
--color-error: 6 78% 57%;
--color-success: 145 63% 49%;
--color-warning: 48 100% 50%;
--color-input: 0 0% 14%;
--color-ring: 47 95% 58%;
} }
/* Nature Theme */ /* ===== Lume Theme (Gold) ===== */
[data-theme="lume"] {
--color-primary: 47 95% 58%;
--color-primary-foreground: 0 0% 0%;
--color-secondary: 47 100% 41%;
--color-secondary-foreground: 0 0% 0%;
--color-background: 0 0% 87%;
--color-foreground: 0 0% 17%;
--color-surface: 0 0% 100%;
--color-surface-hover: 0 0% 96%;
--color-surface-elevated: 0 0% 100%;
--color-muted: 0 0% 90%;
--color-muted-foreground: 0 0% 40%;
--color-border: 0 0% 90%;
--color-border-strong: 0 0% 80%;
--color-error: 6 78% 57%;
--color-success: 145 63% 42%;
--color-warning: 36 100% 50%;
--color-input: 0 0% 100%;
--color-ring: 47 95% 58%;
}
[data-theme="lume"].dark,
.dark[data-theme="lume"] {
--color-primary: 47 95% 58%;
--color-primary-foreground: 0 0% 0%;
--color-secondary: 47 70% 29%;
--color-secondary-foreground: 0 0% 100%;
--color-background: 0 0% 6%;
--color-foreground: 0 0% 100%;
--color-surface: 0 0% 12%;
--color-surface-hover: 0 0% 16%;
--color-surface-elevated: 0 0% 14%;
--color-muted: 0 0% 20%;
--color-muted-foreground: 0 0% 60%;
--color-border: 0 0% 26%;
--color-border-strong: 0 0% 35%;
--color-error: 6 78% 57%;
--color-success: 145 63% 49%;
--color-warning: 48 100% 50%;
--color-input: 0 0% 14%;
--color-ring: 47 95% 58%;
}
/* ===== Nature Theme (Green) ===== */
[data-theme="nature"] { [data-theme="nature"] {
--color-primary: #4CAF50; --color-primary: 122 39% 49%;
--color-primary-button: #A08500; --color-primary-foreground: 0 0% 100%;
--color-primary-button-text: #ffffff; --color-secondary: 122 38% 63%;
--color-secondary: #81C784; --color-secondary-foreground: 0 0% 0%;
--color-secondary-button: #F1F8E9; --color-background: 80 33% 97%;
--color-foreground: 122 56% 24%;
--color-background: #FBFDF8; --color-surface: 0 0% 100%;
--color-foreground: #1B5E20; --color-surface-hover: 120 25% 95%;
--color-content: #F1F8E9; --color-surface-elevated: 0 0% 100%;
--color-content-hover: #E8F5E9; --color-muted: 120 25% 95%;
--color-content-page: #ffffff; --color-muted-foreground: 122 20% 40%;
--color-menu: #E8F5E9; --color-border: 120 25% 91%;
--color-menu-hover: #C8E6C9; --color-border-strong: 120 26% 79%;
--color-error: 0 65% 67%;
--color-text: #1B5E20; --color-success: 122 39% 49%;
--color-text-secondary: #388E3C; --color-warning: 36 100% 50%;
--color-input: 0 0% 100%;
--color-border-light: #E8F5E9; --color-ring: 122 39% 49%;
--color-border: #C8E6C9;
--color-border-strong: #A5D6A7;
--color-error: #E57373;
--color-success: #66BB6A;
--color-warning: #FFB74D;
} }
[data-theme="nature"].dark, [data-theme="nature"].dark,
[data-theme="nature"] .dark { .dark[data-theme="nature"] {
--color-primary: #4CAF50; --color-primary: 122 39% 49%;
--color-primary-button: #FF9500; --color-primary-foreground: 0 0% 100%;
--color-primary-button-text: #000000; --color-secondary: 122 30% 35%;
--color-secondary: #81C784; --color-secondary-foreground: 0 0% 100%;
--color-secondary-button: #1E1E1E; --color-background: 0 0% 7%;
--color-foreground: 0 0% 100%;
--color-background: #121212; --color-surface: 120 10% 12%;
--color-foreground: #FFFFFF; --color-surface-hover: 120 10% 16%;
--color-content: #1E1E1E; --color-surface-elevated: 120 10% 14%;
--color-content-hover: #2E7D32; --color-muted: 120 10% 20%;
--color-content-page: #121212; --color-muted-foreground: 120 10% 60%;
--color-menu: #252525; --color-border: 120 10% 25%;
--color-menu-hover: #2E7D32; --color-border-strong: 120 10% 35%;
--color-error: 0 65% 57%;
--color-text: #FFFFFF; --color-success: 122 50% 55%;
--color-text-secondary: #A5D6A7; --color-warning: 48 100% 50%;
--color-input: 120 10% 14%;
--color-border-light: #1B5E20; --color-ring: 122 39% 49%;
--color-border: #2E7D32;
--color-border-strong: #388E3C;
--color-error: #CF6679;
--color-success: #81C784;
--color-warning: #FFD54F;
} }
/* Stone Theme */ /* ===== Stone Theme (Blue Gray) ===== */
[data-theme="stone"] { [data-theme="stone"] {
--color-primary: #607D8B; --color-primary: 200 18% 46%;
--color-primary-button: #FF9500; --color-primary-foreground: 0 0% 100%;
--color-primary-button-text: #000000; --color-secondary: 200 15% 62%;
--color-secondary: #90A4AE; --color-secondary-foreground: 0 0% 0%;
--color-secondary-button: #ECEFF1; --color-background: 210 17% 97%;
--color-foreground: 200 19% 18%;
--color-background: #F5F7F9; --color-surface: 0 0% 100%;
--color-foreground: #263238; --color-surface-hover: 200 10% 94%;
--color-content: #ECEFF1; --color-surface-elevated: 0 0% 100%;
--color-content-hover: #E0E6EA; --color-muted: 200 10% 94%;
--color-content-page: #ffffff; --color-muted-foreground: 200 10% 45%;
--color-menu: #E0E6EA; --color-border: 200 10% 88%;
--color-menu-hover: #CFD8DC; --color-border-strong: 200 12% 75%;
--color-error: 4 90% 63%;
--color-text: #263238; --color-success: 145 63% 42%;
--color-text-secondary: #546E7A; --color-warning: 36 100% 50%;
--color-input: 0 0% 100%;
--color-border-light: #ECEFF1; --color-ring: 200 18% 46%;
--color-border: #CFD8DC;
--color-border-strong: #B0BEC5;
--color-error: #EF5350;
--color-success: #66BB6A;
--color-warning: #FFA726;
} }
[data-theme="stone"].dark, [data-theme="stone"].dark,
[data-theme="stone"] .dark { .dark[data-theme="stone"] {
--color-primary: #78909C; --color-primary: 200 15% 52%;
--color-primary-button: #FF9500; --color-primary-foreground: 0 0% 0%;
--color-primary-button-text: #000000; --color-secondary: 200 12% 35%;
--color-secondary: #90A4AE; --color-secondary-foreground: 0 0% 100%;
--color-secondary-button: #1E1E1E; --color-background: 0 0% 7%;
--color-foreground: 0 0% 100%;
--color-background: #121212; --color-surface: 200 10% 12%;
--color-foreground: #FFFFFF; --color-surface-hover: 200 10% 16%;
--color-content: #1E1E1E; --color-surface-elevated: 200 10% 14%;
--color-content-hover: #37474F; --color-muted: 200 10% 20%;
--color-content-page: #121212; --color-muted-foreground: 200 10% 60%;
--color-menu: #252525; --color-border: 200 10% 25%;
--color-menu-hover: #37474F; --color-border-strong: 200 10% 35%;
--color-error: 4 90% 58%;
--color-text: #FFFFFF; --color-success: 145 63% 49%;
--color-text-secondary: #B0BEC5; --color-warning: 48 100% 50%;
--color-input: 200 10% 14%;
--color-border-light: #37474F; --color-ring: 200 15% 52%;
--color-border: #455A64;
--color-border-strong: #546E7A;
--color-error: #CF6679;
--color-success: #81C784;
--color-warning: #FFD54F;
} }
/* Ocean Theme */ /* ===== Ocean Theme (Blue) ===== */
[data-theme="ocean"] { [data-theme="ocean"] {
--color-primary: #039BE5; --color-primary: 199 98% 45%;
--color-primary-button: #FF9500; --color-primary-foreground: 0 0% 100%;
--color-primary-button-text: #000000; --color-secondary: 199 92% 64%;
--color-secondary: #4FC3F7; --color-secondary-foreground: 0 0% 0%;
--color-secondary-button: #E1F5FE; --color-background: 199 100% 97%;
--color-foreground: 199 100% 18%;
--color-background: #F5FCFF; --color-surface: 0 0% 100%;
--color-foreground: #01579B; --color-surface-hover: 199 100% 94%;
--color-content: #E1F5FE; --color-surface-elevated: 0 0% 100%;
--color-content-hover: #B3E5FC; --color-muted: 199 100% 94%;
--color-content-page: #ffffff; --color-muted-foreground: 199 50% 40%;
--color-menu: #E1F5FE; --color-border: 199 71% 87%;
--color-menu-hover: #B3E5FC; --color-border-strong: 199 79% 76%;
--color-error: 4 90% 63%;
--color-text: #01579B; --color-success: 145 63% 42%;
--color-text-secondary: #0277BD; --color-warning: 36 100% 50%;
--color-input: 0 0% 100%;
--color-border-light: #E1F5FE; --color-ring: 199 98% 45%;
--color-border: #B3E5FC;
--color-border-strong: #81D4FA;
--color-error: #EF5350;
--color-success: #66BB6A;
--color-warning: #FFA726;
} }
[data-theme="ocean"].dark, [data-theme="ocean"].dark,
[data-theme="ocean"] .dark { .dark[data-theme="ocean"] {
--color-primary: #039BE5; --color-primary: 199 98% 48%;
--color-primary-button: #FF9500; --color-primary-foreground: 0 0% 0%;
--color-primary-button-text: #000000; --color-secondary: 199 60% 35%;
--color-secondary: #4FC3F7; --color-secondary-foreground: 0 0% 100%;
--color-secondary-button: #1E1E1E; --color-background: 0 0% 7%;
--color-foreground: 0 0% 100%;
--color-background: #121212; --color-surface: 199 30% 12%;
--color-foreground: #FFFFFF; --color-surface-hover: 199 30% 16%;
--color-content: #1E1E1E; --color-surface-elevated: 199 30% 14%;
--color-content-hover: #0277BD; --color-muted: 199 20% 20%;
--color-content-page: #121212; --color-muted-foreground: 199 20% 60%;
--color-menu: #252525; --color-border: 199 20% 25%;
--color-menu-hover: #0277BD; --color-border-strong: 199 20% 35%;
--color-error: 4 90% 58%;
--color-text: #FFFFFF; --color-success: 145 63% 49%;
--color-text-secondary: #81D4FA; --color-warning: 48 100% 50%;
--color-input: 199 30% 14%;
--color-border-light: #01579B; --color-ring: 199 98% 48%;
--color-border: #0277BD;
--color-border-strong: #0288D1;
--color-error: #CF6679;
--color-success: #81C784;
--color-warning: #FFD54F;
} }
/* Dark mode via media query */ /* ===== Dark mode via media query (fallback) ===== */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme]) { :root:not(.dark):not([data-theme]) {
--color-primary: #f8d62b; --color-primary: 47 95% 58%;
--color-primary-button: #7C6B16; --color-primary-foreground: 0 0% 0%;
--color-primary-button-text: #ffffff; --color-secondary: 47 70% 29%;
--color-secondary: #D4B200; --color-secondary-foreground: 0 0% 100%;
--color-secondary-button: #1E1E1E; --color-background: 0 0% 6%;
--color-foreground: 0 0% 100%;
--color-background: #101010; --color-surface: 0 0% 12%;
--color-foreground: #ffffff; --color-surface-hover: 0 0% 16%;
--color-content: #1E1E1E; --color-surface-elevated: 0 0% 14%;
--color-content-hover: #333333; --color-muted: 0 0% 20%;
--color-content-page: #121212; --color-muted-foreground: 0 0% 60%;
--color-menu: #101010; --color-border: 0 0% 26%;
--color-menu-hover: #333333; --color-border-strong: 0 0% 35%;
--color-error: 6 78% 57%;
--color-text: #ffffff; --color-success: 145 63% 49%;
--color-text-secondary: #a0a0a0; --color-warning: 48 100% 50%;
--color-input: 0 0% 14%;
--color-border-light: #333333; --color-ring: 47 95% 58%;
--color-border: #424242; }
--color-border-strong: #616161; }
--color-error: #e74c3c; /* ===== Base Styles ===== */
--color-success: #2ecc71; @layer base {
--color-warning: #f1c40f; * {
border-color: hsl(var(--color-border));
}
body {
background-color: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
/* Smooth color transitions for theme switching */
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
}
/* ===== Component Utilities ===== */
@layer components {
/* Primary Button */
.btn-primary {
background-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-weight: 500;
transition: all 0.2s ease;
}
.btn-primary:hover {
filter: brightness(0.9);
}
.btn-primary:focus-visible {
outline: 2px solid hsl(var(--color-ring));
outline-offset: 2px;
}
/* Secondary Button */
.btn-secondary {
background-color: hsl(var(--color-secondary));
color: hsl(var(--color-secondary-foreground));
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-weight: 500;
transition: all 0.2s ease;
}
.btn-secondary:hover {
filter: brightness(0.95);
}
/* Ghost Button */
.btn-ghost {
background-color: transparent;
color: hsl(var(--color-foreground));
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-weight: 500;
transition: all 0.2s ease;
}
.btn-ghost:hover {
background-color: hsl(var(--color-surface-hover));
}
/* Card */
.card {
background-color: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg);
padding: 1rem;
}
.card-elevated {
background-color: hsl(var(--color-surface-elevated));
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
/* Input */
.input {
background-color: hsl(var(--color-input));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
color: hsl(var(--color-foreground));
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.input:focus {
border-color: hsl(var(--color-ring));
box-shadow: 0 0 0 2px hsl(var(--color-ring) / 0.2);
outline: none;
}
.input::placeholder {
color: hsl(var(--color-muted-foreground));
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
font-size: 0.75rem;
font-weight: 500;
background-color: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
.badge-primary {
background-color: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
.badge-success {
background-color: hsl(var(--color-success) / 0.1);
color: hsl(var(--color-success));
}
.badge-error {
background-color: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.badge-warning {
background-color: hsl(var(--color-warning) / 0.1);
color: hsl(var(--color-warning));
} }
} }

View file

@ -0,0 +1,170 @@
# @manacore/shared-theme-ui
Svelte UI components for theme switching. Works with `@manacore/shared-theme`.
## Features
- **ThemeToggle**: Simple light/dark mode toggle button
- **ThemeSelector**: Visual selector for theme variants
- **ThemeModeSelector**: Segmented control for light/dark/system
## Installation
```bash
pnpm add @manacore/shared-theme-ui
```
## Prerequisites
- `@manacore/shared-theme` - Theme store
- `@manacore/shared-icons` - Icon components
## Components
### ThemeToggle
A simple button that toggles between light and dark mode.
```svelte
<script lang="ts">
import { theme } from '$lib/stores/theme';
import { ThemeToggle } from '@manacore/shared-theme-ui';
</script>
<ThemeToggle {theme} />
<!-- With options -->
<ThemeToggle
{theme}
size={24}
showTooltip={true}
class="my-custom-class"
/>
```
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `theme` | `ThemeStore` | required | Theme store instance |
| `size` | `number` | `20` | Icon size in pixels |
| `showTooltip` | `boolean` | `false` | Show tooltip on hover |
| `class` | `string` | `''` | Additional CSS classes |
### ThemeSelector
A visual selector showing all theme variants with color dots.
```svelte
<script lang="ts">
import { theme } from '$lib/stores/theme';
import { ThemeSelector } from '@manacore/shared-theme-ui';
</script>
<ThemeSelector {theme} />
<!-- With options -->
<ThemeSelector
{theme}
showLabels={true}
showEmoji={true}
compact={false}
class="my-custom-class"
/>
```
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `theme` | `ThemeStore` | required | Theme store instance |
| `showLabels` | `boolean` | `true` | Show variant labels |
| `showEmoji` | `boolean` | `true` | Show variant emojis |
| `compact` | `boolean` | `false` | Compact mode (smaller buttons) |
| `class` | `string` | `''` | Additional CSS classes |
### ThemeModeSelector
A segmented control for selecting light, dark, or system mode.
```svelte
<script lang="ts">
import { theme } from '$lib/stores/theme';
import { ThemeModeSelector } from '@manacore/shared-theme-ui';
</script>
<ThemeModeSelector {theme} />
<!-- With options -->
<ThemeModeSelector
{theme}
class="my-custom-class"
/>
```
#### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `theme` | `ThemeStore` | required | Theme store instance |
| `class` | `string` | `''` | Additional CSS classes |
## Complete Example
```svelte
<script lang="ts">
import { theme } from '$lib/stores/theme';
import {
ThemeToggle,
ThemeSelector,
ThemeModeSelector
} from '@manacore/shared-theme-ui';
</script>
<div class="settings-panel">
<h3>Appearance</h3>
<!-- Quick toggle in header -->
<div class="header">
<ThemeToggle {theme} showTooltip />
</div>
<!-- Mode selection -->
<section>
<label>Mode</label>
<ThemeModeSelector {theme} />
</section>
<!-- Variant selection -->
<section>
<label>Color Theme</label>
<ThemeSelector {theme} />
</section>
</div>
```
## Styling
All components use CSS variables from `@manacore/shared-tailwind/themes.css` and are fully theme-aware. They automatically adapt to the current theme variant and mode.
### Custom Styling
You can override styles using the `class` prop or by targeting the component classes:
```css
/* Custom toggle button */
.theme-toggle {
background-color: var(--my-custom-bg);
}
/* Custom selector buttons */
.variant-button.active {
box-shadow: 0 0 0 2px var(--my-custom-ring);
}
```
## Related Packages
- `@manacore/shared-theme` - Theme store and utilities
- `@manacore/shared-tailwind` - Tailwind preset with theme CSS
- `@manacore/shared-icons` - Icon components

View file

@ -0,0 +1,25 @@
{
"name": "@manacore/shared-theme-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./ThemeToggle.svelte": "./src/ThemeToggle.svelte",
"./ThemeSelector.svelte": "./src/ThemeSelector.svelte",
"./ThemeModeSelector.svelte": "./src/ThemeModeSelector.svelte"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"dependencies": {
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-icons": "workspace:*"
},
"devDependencies": {
"svelte": "^5.0.0",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,87 @@
<script lang="ts">
import type { ThemeStore, ThemeMode } from '@manacore/shared-theme';
import { Icon } from '@manacore/shared-icons';
interface Props {
/** Theme store instance */
theme: ThemeStore;
/** Additional CSS classes */
class?: string;
}
let { theme, class: className = '' }: Props = $props();
const modes: { mode: ThemeMode; icon: string; label: string }[] = [
{ mode: 'light', icon: 'sun', label: 'Light' },
{ mode: 'dark', icon: 'moon', label: 'Dark' },
{ mode: 'system', icon: 'monitor', label: 'System' },
];
</script>
<div class="mode-selector {className}" role="radiogroup" aria-label="Theme mode">
{#each modes as { mode, icon, label }}
{@const isActive = theme.mode === mode}
<button
type="button"
onclick={() => theme.setMode(mode)}
class="mode-button"
class:active={isActive}
role="radio"
aria-checked={isActive}
aria-label="{label} mode"
>
<Icon name={icon} size={16} />
<span class="label">{label}</span>
</button>
{/each}
</div>
<style>
.mode-selector {
display: inline-flex;
padding: 0.25rem;
gap: 0.25rem;
background-color: hsl(var(--color-muted));
border-radius: 0.5rem;
}
.mode-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
}
.mode-button:hover:not(.active) {
color: hsl(var(--color-foreground));
}
.mode-button.active {
background-color: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.mode-button:focus-visible {
outline: 2px solid hsl(var(--color-ring));
outline-offset: 2px;
}
.label {
display: none;
}
@media (min-width: 640px) {
.label {
display: inline;
}
}
</style>

View file

@ -0,0 +1,125 @@
<script lang="ts">
import type { ThemeStore, ThemeVariant } from '@manacore/shared-theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
interface Props {
/** Theme store instance */
theme: ThemeStore;
/** Show variant labels */
showLabels?: boolean;
/** Show emoji icons */
showEmoji?: boolean;
/** Compact mode (smaller buttons) */
compact?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
theme,
showLabels = true,
showEmoji = true,
compact = false,
class: className = '',
}: Props = $props();
/**
* Get the primary color for a variant based on current mode
*/
function getVariantColor(variant: ThemeVariant): string {
const definition = THEME_DEFINITIONS[variant];
const colors = theme.effectiveMode === 'dark' ? definition.dark : definition.light;
return `hsl(${colors.primary})`;
}
</script>
<div class="theme-selector {className}" class:compact>
{#each theme.variants as variant}
{@const definition = THEME_DEFINITIONS[variant]}
{@const isActive = theme.variant === variant}
<button
type="button"
onclick={() => theme.setVariant(variant)}
class="variant-button"
class:active={isActive}
aria-label="Select {definition.label} theme"
aria-pressed={isActive}
>
<span
class="color-dot"
style:background-color={getVariantColor(variant)}
></span>
{#if showEmoji && !compact}
<span class="emoji">{definition.emoji}</span>
{/if}
{#if showLabels && !compact}
<span class="label">{definition.label}</span>
{/if}
</button>
{/each}
</div>
<style>
.theme-selector {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.variant-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: transparent;
border: 1px solid hsl(var(--color-border));
cursor: pointer;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: all 0.2s ease;
}
.variant-button:hover {
background-color: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-border-strong));
}
.variant-button.active {
background-color: hsl(var(--color-surface));
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 1px hsl(var(--color-primary));
}
.variant-button:focus-visible {
outline: 2px solid hsl(var(--color-ring));
outline-offset: 2px;
}
.color-dot {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.emoji {
font-size: 1rem;
}
.label {
font-weight: 500;
}
/* Compact mode */
.compact .variant-button {
padding: 0.375rem;
border-radius: 0.375rem;
}
.compact .color-dot {
width: 1.25rem;
height: 1.25rem;
}
</style>

View file

@ -0,0 +1,74 @@
<script lang="ts">
import type { ThemeStore } from '@manacore/shared-theme';
import { Icon } from '@manacore/shared-icons';
interface Props {
/** Theme store instance */
theme: ThemeStore;
/** Icon size in pixels */
size?: number;
/** Additional CSS classes */
class?: string;
/** Show tooltip */
showTooltip?: boolean;
}
let { theme, size = 20, class: className = '', showTooltip = false }: Props = $props();
function getTooltipText(): string {
if (theme.mode === 'system') {
return `System (${theme.effectiveMode})`;
}
return theme.effectiveMode === 'dark' ? 'Dark mode' : 'Light mode';
}
</script>
<button
type="button"
onclick={() => theme.toggleMode()}
class="theme-toggle {className}"
aria-label="Toggle theme"
title={showTooltip ? getTooltipText() : undefined}
>
{#if theme.effectiveMode === 'dark'}
<Icon name="sun" {size} class="theme-toggle-icon" />
{:else}
<Icon name="moon" {size} class="theme-toggle-icon" />
{/if}
</button>
<style>
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--color-foreground));
transition: background-color 0.2s ease, transform 0.1s ease;
}
.theme-toggle:hover {
background-color: hsl(var(--color-surface-hover));
}
.theme-toggle:active {
transform: scale(0.95);
}
.theme-toggle:focus-visible {
outline: 2px solid hsl(var(--color-ring));
outline-offset: 2px;
}
:global(.theme-toggle-icon) {
transition: transform 0.3s ease;
}
.theme-toggle:hover :global(.theme-toggle-icon) {
transform: rotate(15deg);
}
</style>

View file

@ -0,0 +1,4 @@
// Theme UI Components
export { default as ThemeToggle } from './ThemeToggle.svelte';
export { default as ThemeSelector } from './ThemeSelector.svelte';
export { default as ThemeModeSelector } from './ThemeModeSelector.svelte';

View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,225 @@
# @manacore/shared-theme
Unified theme system for all ManaCore web applications. Provides a consistent theming experience with HSL-based colors, multiple theme variants, and light/dark mode support.
## Features
- **4 Theme Variants**: Lume (Gold), Nature (Green), Stone (Blue Gray), Ocean (Blue)
- **3 Theme Modes**: Light, Dark, System (auto-detect)
- **HSL-based Colors**: 18 semantic color tokens for flexible theming
- **App-specific Primary Colors**: Each app can override the primary color
- **Svelte 5 Runes**: Modern reactive state management
- **localStorage Persistence**: Theme preferences are saved per app
- **System Preference Detection**: Automatically follows OS dark mode setting
## Installation
```bash
pnpm add @manacore/shared-theme
```
## Quick Start
### 1. Create a theme store for your app
```typescript
// src/lib/stores/theme.ts
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'myapp',
defaultVariant: 'lume',
primaryColor: {
light: '47 95% 58%', // Gold
dark: '47 95% 58%',
},
});
```
### 2. Initialize in your layout
```svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
onMount(() => {
const cleanup = theme.initialize();
return cleanup;
});
</script>
{@render children()}
```
### 3. Import theme CSS
```css
/* src/app.css */
@import '@manacore/shared-tailwind/themes.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
```
## API Reference
### `createThemeStore(config)`
Creates a theme store instance for your app.
#### Config Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `appId` | `string` | required | Unique app identifier for localStorage |
| `defaultMode` | `'light' \| 'dark' \| 'system'` | `'system'` | Default theme mode |
| `defaultVariant` | `ThemeVariant` | `'lume'` | Default theme variant |
| `primaryColor` | `{ light: HSLValue; dark: HSLValue }` | - | App-specific primary color override |
#### Store Properties
| Property | Type | Description |
|----------|------|-------------|
| `mode` | `ThemeMode` | Current mode (light/dark/system) |
| `variant` | `ThemeVariant` | Current variant (lume/nature/stone/ocean) |
| `effectiveMode` | `'light' \| 'dark'` | Resolved mode (accounts for system preference) |
| `isDark` | `boolean` | Whether dark mode is active |
| `variants` | `ThemeVariant[]` | All available variants |
#### Store Methods
| Method | Description |
|--------|-------------|
| `initialize()` | Initialize theme, returns cleanup function |
| `setMode(mode)` | Set theme mode |
| `setVariant(variant)` | Set theme variant |
| `toggleMode()` | Toggle between light and dark |
| `cycleMode()` | Cycle through light → dark → system |
## Theme Variants
### Lume (Gold) ✨
- Primary: `hsl(47 95% 58%)` - #f8d62b
- Warm, energetic feel
- Best for: Creative apps, productivity tools
### Nature (Green) 🌿
- Primary: `hsl(122 39% 49%)` - #4CAF50
- Calm, organic feel
- Best for: Health apps, environmental themes
### Stone (Blue Gray) 🪨
- Primary: `hsl(200 18% 46%)` - #607D8B
- Professional, neutral feel
- Best for: Business apps, enterprise tools
### Ocean (Blue) 🌊
- Primary: `hsl(199 98% 45%)` - #039BE5
- Fresh, trustworthy feel
- Best for: Tech apps, communication tools
## Color Tokens
The theme system provides 18 semantic color tokens:
```css
/* Primary */
--color-primary
--color-primary-foreground
/* Secondary */
--color-secondary
--color-secondary-foreground
/* Backgrounds */
--color-background
--color-foreground
/* Surfaces */
--color-surface
--color-surface-hover
--color-surface-elevated
/* Muted */
--color-muted
--color-muted-foreground
/* Borders */
--color-border
--color-border-strong
/* Semantic */
--color-error
--color-success
--color-warning
/* Form */
--color-input
--color-ring
```
## Usage with Tailwind
The `@manacore/shared-tailwind` preset maps all CSS variables to Tailwind classes:
```html
<!-- Backgrounds -->
<div class="bg-background">Page background</div>
<div class="bg-surface">Card surface</div>
<div class="bg-surface-hover">Hover state</div>
<!-- Text -->
<p class="text-foreground">Main text</p>
<p class="text-muted-foreground">Secondary text</p>
<!-- Primary -->
<button class="bg-primary text-primary-foreground">
Primary Button
</button>
<!-- Borders -->
<div class="border border-border">Normal border</div>
<div class="border border-border-strong">Strong border</div>
<!-- Semantic -->
<span class="text-error">Error message</span>
<span class="text-success">Success message</span>
```
## Pre-defined App Configs
```typescript
import { APP_THEME_CONFIGS } from '@manacore/shared-theme';
// Use pre-defined config
export const theme = createThemeStore(APP_THEME_CONFIGS.memoro);
// Available configs:
// - APP_THEME_CONFIGS.memoro (Gold)
// - APP_THEME_CONFIGS.manacore (Indigo)
// - APP_THEME_CONFIGS.manadeck (Indigo)
// - APP_THEME_CONFIGS.maerchenzauber (Purple)
```
## TypeScript Types
```typescript
import type {
ThemeMode, // 'light' | 'dark' | 'system'
ThemeVariant, // 'lume' | 'nature' | 'stone' | 'ocean'
EffectiveMode, // 'light' | 'dark'
ThemeState, // Full theme state object
ThemeColors, // Color token definitions
AppThemeConfig, // Store configuration
ThemeStore, // Store interface
HSLValue, // HSL string format
} from '@manacore/shared-theme';
```
## Related Packages
- `@manacore/shared-tailwind` - Tailwind preset with theme CSS variables
- `@manacore/shared-theme-ui` - Theme toggle and selector components

View file

@ -0,0 +1,22 @@
{
"name": "@manacore/shared-theme",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./store": "./src/store.svelte.ts",
"./types": "./src/types.ts",
"./constants": "./src/constants.ts",
"./utils": "./src/utils.ts"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": {
"svelte": "^5.0.0",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,244 @@
import type { ThemeVariant, ThemeVariantDefinition, ThemeColors } from './types';
/**
* All available theme variants
*/
export const THEME_VARIANTS: readonly ThemeVariant[] = ['lume', 'nature', 'stone', 'ocean'] as const;
/**
* HSL Color Definitions for all theme variants
*
* Format: "H S% L%" (without hsl() wrapper for CSS variable compatibility)
*
* Color tokens:
* - primary: Main brand/accent color
* - secondary: Secondary accent
* - background: Page background
* - foreground: Main text color
* - surface: Card/content background
* - muted: Disabled/subtle elements
* - border: Border colors
* - error/success/warning: Semantic colors
*/
const lumeLight: ThemeColors = {
primary: '47 95% 58%', // #f8d62b - Gold
primaryForeground: '0 0% 0%', // Black text on gold
secondary: '47 100% 41%', // #D4B200 - Darker gold
secondaryForeground: '0 0% 0%',
background: '0 0% 87%', // #dddddd - Light gray
foreground: '0 0% 17%', // #2c2c2c - Dark text
surface: '0 0% 100%', // #ffffff - White
surfaceHover: '0 0% 96%', // #f5f5f5
surfaceElevated: '0 0% 100%', // #ffffff
muted: '0 0% 90%', // #e6e6e6
mutedForeground: '0 0% 40%', // #666666
border: '0 0% 90%', // #e6e6e6
borderStrong: '0 0% 80%', // #cccccc
error: '6 78% 57%', // #e74c3c
success: '145 63% 42%', // #27ae60
warning: '36 100% 50%', // #f39c12
input: '0 0% 100%', // #ffffff
ring: '47 95% 58%', // Same as primary
};
const lumeDark: ThemeColors = {
primary: '47 95% 58%', // #f8d62b - Gold (same in dark)
primaryForeground: '0 0% 0%', // Black text on gold
secondary: '47 70% 29%', // #7C6B16 - Muted gold
secondaryForeground: '0 0% 100%',
background: '0 0% 6%', // #101010 - Very dark
foreground: '0 0% 100%', // #ffffff - White text
surface: '0 0% 12%', // #1E1E1E - Dark surface
surfaceHover: '0 0% 16%', // #292929
surfaceElevated: '0 0% 14%', // #242424
muted: '0 0% 20%', // #333333
mutedForeground: '0 0% 60%', // #999999
border: '0 0% 26%', // #424242
borderStrong: '0 0% 35%', // #595959
error: '6 78% 57%', // #e74c3c
success: '145 63% 49%', // #2ecc71
warning: '48 100% 50%', // #f1c40f
input: '0 0% 14%', // #242424
ring: '47 95% 58%',
};
const natureLight: ThemeColors = {
primary: '122 39% 49%', // #4CAF50 - Green
primaryForeground: '0 0% 100%', // White text on green
secondary: '122 38% 63%', // #81C784 - Light green
secondaryForeground: '0 0% 0%',
background: '80 33% 97%', // #FBFDF8 - Very light green tint
foreground: '122 56% 24%', // #1B5E20 - Dark green text
surface: '0 0% 100%', // #ffffff
surfaceHover: '120 25% 95%', // #F1F8E9
surfaceElevated: '0 0% 100%',
muted: '120 25% 95%', // #F1F8E9
mutedForeground: '122 20% 40%',
border: '120 25% 91%', // #E8F5E9
borderStrong: '120 26% 79%', // #C8E6C9
error: '0 65% 67%', // #E57373
success: '122 39% 49%', // Same as primary
warning: '36 100% 50%',
input: '0 0% 100%',
ring: '122 39% 49%',
};
const natureDark: ThemeColors = {
primary: '122 39% 49%', // #4CAF50
primaryForeground: '0 0% 100%',
secondary: '122 30% 35%', // Muted green
secondaryForeground: '0 0% 100%',
background: '0 0% 7%', // #121212
foreground: '0 0% 100%', // White
surface: '120 10% 12%', // Slight green tint
surfaceHover: '120 10% 16%',
surfaceElevated: '120 10% 14%',
muted: '120 10% 20%',
mutedForeground: '120 10% 60%',
border: '120 10% 25%',
borderStrong: '120 10% 35%',
error: '0 65% 57%',
success: '122 50% 55%',
warning: '48 100% 50%',
input: '120 10% 14%',
ring: '122 39% 49%',
};
const stoneLight: ThemeColors = {
primary: '200 18% 46%', // #607D8B - Blue gray
primaryForeground: '0 0% 100%',
secondary: '200 15% 62%', // #90A4AE - Light slate
secondaryForeground: '0 0% 0%',
background: '210 17% 97%', // #F5F7F9 - Very light blue gray
foreground: '200 19% 18%', // #263238 - Dark slate
surface: '0 0% 100%',
surfaceHover: '200 10% 94%', // #ECEFF1
surfaceElevated: '0 0% 100%',
muted: '200 10% 94%', // #ECEFF1
mutedForeground: '200 10% 45%',
border: '200 10% 88%', // #CFD8DC
borderStrong: '200 12% 75%', // #B0BEC5
error: '4 90% 63%', // #EF5350
success: '145 63% 42%',
warning: '36 100% 50%',
input: '0 0% 100%',
ring: '200 18% 46%',
};
const stoneDark: ThemeColors = {
primary: '200 15% 52%', // #78909C - Lighter in dark mode
primaryForeground: '0 0% 0%',
secondary: '200 12% 35%',
secondaryForeground: '0 0% 100%',
background: '0 0% 7%', // #121212
foreground: '0 0% 100%',
surface: '200 10% 12%',
surfaceHover: '200 10% 16%',
surfaceElevated: '200 10% 14%',
muted: '200 10% 20%',
mutedForeground: '200 10% 60%',
border: '200 10% 25%',
borderStrong: '200 10% 35%',
error: '4 90% 58%',
success: '145 63% 49%',
warning: '48 100% 50%',
input: '200 10% 14%',
ring: '200 15% 52%',
};
const oceanLight: ThemeColors = {
primary: '199 98% 45%', // #039BE5 - Bright blue
primaryForeground: '0 0% 100%',
secondary: '199 92% 64%', // #4FC3F7 - Light blue
secondaryForeground: '0 0% 0%',
background: '199 100% 97%', // #F5FCFF - Very light blue
foreground: '199 100% 18%', // #01579B - Dark blue
surface: '0 0% 100%',
surfaceHover: '199 100% 94%', // #E1F5FE
surfaceElevated: '0 0% 100%',
muted: '199 100% 94%', // #E1F5FE
mutedForeground: '199 50% 40%',
border: '199 71% 87%', // #B3E5FC
borderStrong: '199 79% 76%', // #81D4FA
error: '4 90% 63%', // #EF5350
success: '145 63% 42%',
warning: '36 100% 50%',
input: '0 0% 100%',
ring: '199 98% 45%',
};
const oceanDark: ThemeColors = {
primary: '199 98% 48%', // Slightly brighter in dark
primaryForeground: '0 0% 0%',
secondary: '199 60% 35%',
secondaryForeground: '0 0% 100%',
background: '0 0% 7%', // #121212
foreground: '0 0% 100%',
surface: '199 30% 12%', // Slight blue tint
surfaceHover: '199 30% 16%',
surfaceElevated: '199 30% 14%',
muted: '199 20% 20%',
mutedForeground: '199 20% 60%',
border: '199 20% 25%',
borderStrong: '199 20% 35%',
error: '4 90% 58%',
success: '145 63% 49%',
warning: '48 100% 50%',
input: '199 30% 14%',
ring: '199 98% 48%',
};
/**
* Complete theme variant definitions
*/
export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
lume: {
name: 'lume',
label: 'Lume',
emoji: '✨',
hue: 47,
light: lumeLight,
dark: lumeDark,
},
nature: {
name: 'nature',
label: 'Nature',
emoji: '🌿',
hue: 122,
light: natureLight,
dark: natureDark,
},
stone: {
name: 'stone',
label: 'Stone',
emoji: '🪨',
hue: 200,
light: stoneLight,
dark: stoneDark,
},
ocean: {
name: 'ocean',
label: 'Ocean',
emoji: '🌊',
hue: 199,
light: oceanLight,
dark: oceanDark,
},
};
/**
* Default theme configuration
*/
export const DEFAULT_MODE = 'system' as const;
export const DEFAULT_VARIANT = 'lume' as const;
/**
* CSS variable prefix
*/
export const CSS_VAR_PREFIX = '--color' as const;
/**
* LocalStorage key suffix
*/
export const STORAGE_KEY_SUFFIX = '-theme' as const;

View file

@ -0,0 +1,43 @@
// Types
export type {
ThemeMode,
ThemeVariant,
EffectiveMode,
ThemeState,
ThemeColors,
ThemeVariantDefinition,
AppThemeConfig,
ThemeStore,
HSLValue,
} from './types';
// Constants
export {
THEME_VARIANTS,
THEME_DEFINITIONS,
DEFAULT_MODE,
DEFAULT_VARIANT,
CSS_VAR_PREFIX,
STORAGE_KEY_SUFFIX,
} from './constants';
// Store
export { createThemeStore, APP_THEME_CONFIGS } from './store.svelte';
// Utils
export {
isBrowser,
getSystemPreference,
createSystemPreferenceListener,
getThemeColors,
colorsToCssVars,
applyThemeToDocument,
loadThemeFromStorage,
saveThemeToStorage,
parseHSL,
createHSL,
adjustLightness,
adjustSaturation,
getContrastColor,
generateThemeCSS,
} from './utils';

View file

@ -0,0 +1,227 @@
import type {
ThemeMode,
ThemeVariant,
EffectiveMode,
ThemeStore,
AppThemeConfig,
HSLValue,
} from './types';
import {
THEME_VARIANTS,
DEFAULT_MODE,
DEFAULT_VARIANT,
STORAGE_KEY_SUFFIX,
} from './constants';
import {
isBrowser,
getSystemPreference,
createSystemPreferenceListener,
applyThemeToDocument,
loadThemeFromStorage,
saveThemeToStorage,
} from './utils';
/**
* Create a theme store for your app
*
* @example
* ```typescript
* // Basic usage
* import { createThemeStore } from '@manacore/shared-theme';
*
* export const theme = createThemeStore({ appId: 'myapp' });
*
* // With custom primary color
* export const theme = createThemeStore({
* appId: 'memoro',
* primaryColor: {
* light: '47 95% 58%', // Gold
* dark: '47 95% 58%',
* },
* });
* ```
*/
export function createThemeStore(config: AppThemeConfig): ThemeStore {
const {
appId,
defaultMode = DEFAULT_MODE,
defaultVariant = DEFAULT_VARIANT,
primaryColor,
} = config;
const storageKey = `${appId}${STORAGE_KEY_SUFFIX}`;
// Svelte 5 runes state
let mode = $state<ThemeMode>(defaultMode);
let variant = $state<ThemeVariant>(defaultVariant);
let effectiveMode = $state<EffectiveMode>('light');
// Derived state
const isDark = $derived(effectiveMode === 'dark');
/**
* Calculate effective mode from current mode and system preference
*/
function calculateEffectiveMode(currentMode: ThemeMode): EffectiveMode {
if (currentMode === 'system') {
return getSystemPreference();
}
return currentMode;
}
/**
* Apply current theme to document and save to storage
*/
function applyTheme(): void {
const newEffectiveMode = calculateEffectiveMode(mode);
effectiveMode = newEffectiveMode;
applyThemeToDocument(variant, newEffectiveMode, primaryColor);
saveThemeToStorage(storageKey, mode, variant);
}
/**
* Set theme mode
*/
function setMode(newMode: ThemeMode): void {
if (newMode === mode) return;
mode = newMode;
applyTheme();
}
/**
* Set theme variant
*/
function setVariant(newVariant: ThemeVariant): void {
if (!THEME_VARIANTS.includes(newVariant)) {
console.warn(`Invalid theme variant: ${newVariant}`);
return;
}
if (newVariant === variant) return;
variant = newVariant;
applyTheme();
}
/**
* Toggle between light and dark mode
* If currently on system, switches to opposite of effective mode
*/
function toggleMode(): void {
if (mode === 'system') {
// Switch to opposite of current effective mode
setMode(effectiveMode === 'dark' ? 'light' : 'dark');
} else {
setMode(mode === 'dark' ? 'light' : 'dark');
}
}
/**
* Cycle through modes: light dark system light
*/
function cycleMode(): void {
const modes: ThemeMode[] = ['light', 'dark', 'system'];
const currentIndex = modes.indexOf(mode);
const nextIndex = (currentIndex + 1) % modes.length;
setMode(modes[nextIndex]);
}
/**
* Initialize theme store
* - Loads saved preferences from localStorage
* - Sets up system preference listener
* - Applies initial theme
*
* @returns Cleanup function to remove listeners
*/
function initialize(): () => void {
if (!isBrowser()) {
return () => {};
}
// Load saved preferences
const saved = loadThemeFromStorage(storageKey);
if (saved) {
if (saved.mode && ['light', 'dark', 'system'].includes(saved.mode)) {
mode = saved.mode as ThemeMode;
}
if (saved.variant && THEME_VARIANTS.includes(saved.variant as ThemeVariant)) {
variant = saved.variant as ThemeVariant;
}
}
// Apply initial theme
applyTheme();
// Listen for system preference changes
const cleanup = createSystemPreferenceListener((isDark) => {
if (mode === 'system') {
effectiveMode = isDark ? 'dark' : 'light';
applyThemeToDocument(variant, effectiveMode, primaryColor);
}
});
return cleanup;
}
return {
get mode() {
return mode;
},
get variant() {
return variant;
},
get effectiveMode() {
return effectiveMode;
},
get isDark() {
return isDark;
},
get variants() {
return THEME_VARIANTS;
},
setMode,
setVariant,
toggleMode,
cycleMode,
initialize,
};
}
/**
* Pre-defined app configurations for convenience
*/
export const APP_THEME_CONFIGS = {
memoro: {
appId: 'memoro',
defaultVariant: 'lume' as ThemeVariant,
primaryColor: {
light: '47 95% 58%' as HSLValue, // Gold #f8d62b
dark: '47 95% 58%' as HSLValue,
},
},
manacore: {
appId: 'manacore',
defaultVariant: 'ocean' as ThemeVariant,
primaryColor: {
light: '239 84% 67%' as HSLValue, // Indigo #6366f1
dark: '239 84% 67%' as HSLValue,
},
},
manadeck: {
appId: 'manadeck',
defaultVariant: 'ocean' as ThemeVariant,
primaryColor: {
light: '239 84% 67%' as HSLValue, // Indigo #6366f1
dark: '239 84% 67%' as HSLValue,
},
},
maerchenzauber: {
appId: 'maerchenzauber',
defaultVariant: 'nature' as ThemeVariant,
primaryColor: {
light: '280 60% 55%' as HSLValue, // Purple (storytelling magic)
dark: '280 60% 60%' as HSLValue,
},
},
} as const;

View file

@ -0,0 +1,133 @@
/**
* Theme mode - user preference for light/dark appearance
*/
export type ThemeMode = 'light' | 'dark' | 'system';
/**
* Theme variant - visual style/color scheme
* All apps share the same 4 variants
*/
export type ThemeVariant = 'lume' | 'nature' | 'stone' | 'ocean';
/**
* Effective mode - the actual rendered mode (resolved from system preference)
*/
export type EffectiveMode = 'light' | 'dark';
/**
* Complete theme state
*/
export interface ThemeState {
mode: ThemeMode;
variant: ThemeVariant;
effectiveMode: EffectiveMode;
}
/**
* HSL color value (without hsl() wrapper)
* Format: "H S% L%" e.g. "47 95% 58%"
*/
export type HSLValue = string;
/**
* Theme color definition using HSL values
*/
export interface ThemeColors {
/** Primary brand color */
primary: HSLValue;
/** Primary color for text on primary background */
primaryForeground: HSLValue;
/** Secondary accent color */
secondary: HSLValue;
/** Secondary foreground */
secondaryForeground: HSLValue;
/** Page background */
background: HSLValue;
/** Main text color */
foreground: HSLValue;
/** Card/content surface */
surface: HSLValue;
/** Surface hover state */
surfaceHover: HSLValue;
/** Elevated surface (modals, dropdowns) */
surfaceElevated: HSLValue;
/** Muted/disabled elements */
muted: HSLValue;
/** Muted text */
mutedForeground: HSLValue;
/** Border color */
border: HSLValue;
/** Strong border (focus, active) */
borderStrong: HSLValue;
/** Error/destructive color */
error: HSLValue;
/** Success color */
success: HSLValue;
/** Warning color */
warning: HSLValue;
/** Input background */
input: HSLValue;
/** Focus ring color */
ring: HSLValue;
}
/**
* Theme variant definition with light and dark mode colors
*/
export interface ThemeVariantDefinition {
name: string;
label: string;
emoji: string;
/** The primary hue for this variant (used for accent colors) */
hue: number;
light: ThemeColors;
dark: ThemeColors;
}
/**
* App-specific theme configuration
*/
export interface AppThemeConfig {
/** App identifier for localStorage key */
appId: string;
/** Default theme mode */
defaultMode?: ThemeMode;
/** Default theme variant */
defaultVariant?: ThemeVariant;
/**
* App-specific primary color override (HSL value)
* This allows each app to have its own brand color
* while sharing the same theme variants
*/
primaryColor?: {
light: HSLValue;
dark: HSLValue;
};
}
/**
* Theme store interface
*/
export interface ThemeStore {
/** Current theme mode (user preference) */
readonly mode: ThemeMode;
/** Current theme variant */
readonly variant: ThemeVariant;
/** Effective mode (resolved from system) */
readonly effectiveMode: EffectiveMode;
/** Whether dark mode is active */
readonly isDark: boolean;
/** All available variants */
readonly variants: readonly ThemeVariant[];
/** Set theme mode */
setMode: (mode: ThemeMode) => void;
/** Set theme variant */
setVariant: (variant: ThemeVariant) => void;
/** Toggle between light and dark mode */
toggleMode: () => void;
/** Cycle through modes: light → dark → system → light */
cycleMode: () => void;
/** Initialize theme (call on mount) */
initialize: () => () => void;
}

View file

@ -0,0 +1,245 @@
import type { ThemeColors, ThemeVariant, EffectiveMode, HSLValue } from './types';
import { THEME_DEFINITIONS, CSS_VAR_PREFIX } from './constants';
/**
* Check if code is running in browser
*/
export function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined';
}
/**
* Get system color scheme preference
*/
export function getSystemPreference(): EffectiveMode {
if (!isBrowser()) return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Create a media query listener for system preference changes
*/
export function createSystemPreferenceListener(callback: (isDark: boolean) => void): () => void {
if (!isBrowser()) return () => {};
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => callback(e.matches);
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}
// Legacy browsers
mediaQuery.addListener(handler);
return () => mediaQuery.removeListener(handler);
}
/**
* Get colors for a specific variant and mode
*/
export function getThemeColors(
variant: ThemeVariant,
mode: EffectiveMode,
primaryOverride?: { light: HSLValue; dark: HSLValue }
): ThemeColors {
const definition = THEME_DEFINITIONS[variant];
const colors = mode === 'dark' ? { ...definition.dark } : { ...definition.light };
// Apply app-specific primary color override
if (primaryOverride) {
const overrideColor = mode === 'dark' ? primaryOverride.dark : primaryOverride.light;
colors.primary = overrideColor;
colors.ring = overrideColor;
}
return colors;
}
/**
* Convert ThemeColors to CSS variables object
*/
export function colorsToCssVars(colors: ThemeColors): Record<string, string> {
return {
[`${CSS_VAR_PREFIX}-primary`]: colors.primary,
[`${CSS_VAR_PREFIX}-primary-foreground`]: colors.primaryForeground,
[`${CSS_VAR_PREFIX}-secondary`]: colors.secondary,
[`${CSS_VAR_PREFIX}-secondary-foreground`]: colors.secondaryForeground,
[`${CSS_VAR_PREFIX}-background`]: colors.background,
[`${CSS_VAR_PREFIX}-foreground`]: colors.foreground,
[`${CSS_VAR_PREFIX}-surface`]: colors.surface,
[`${CSS_VAR_PREFIX}-surface-hover`]: colors.surfaceHover,
[`${CSS_VAR_PREFIX}-surface-elevated`]: colors.surfaceElevated,
[`${CSS_VAR_PREFIX}-muted`]: colors.muted,
[`${CSS_VAR_PREFIX}-muted-foreground`]: colors.mutedForeground,
[`${CSS_VAR_PREFIX}-border`]: colors.border,
[`${CSS_VAR_PREFIX}-border-strong`]: colors.borderStrong,
[`${CSS_VAR_PREFIX}-error`]: colors.error,
[`${CSS_VAR_PREFIX}-success`]: colors.success,
[`${CSS_VAR_PREFIX}-warning`]: colors.warning,
[`${CSS_VAR_PREFIX}-input`]: colors.input,
[`${CSS_VAR_PREFIX}-ring`]: colors.ring,
};
}
/**
* Apply theme to document
*/
export function applyThemeToDocument(
variant: ThemeVariant,
effectiveMode: EffectiveMode,
primaryOverride?: { light: HSLValue; dark: HSLValue }
): void {
if (!isBrowser()) return;
const root = document.documentElement;
const colors = getThemeColors(variant, effectiveMode, primaryOverride);
const cssVars = colorsToCssVars(colors);
// Set CSS variables
Object.entries(cssVars).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
// Set data-theme attribute
root.setAttribute('data-theme', variant);
// Set dark class
if (effectiveMode === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// Update color-scheme for native elements
root.style.colorScheme = effectiveMode;
}
/**
* Load theme from localStorage
*/
export function loadThemeFromStorage(storageKey: string): { mode?: string; variant?: string } | null {
if (!isBrowser()) return null;
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load theme from storage:', e);
}
return null;
}
/**
* Save theme to localStorage
*/
export function saveThemeToStorage(
storageKey: string,
mode: string,
variant: string
): void {
if (!isBrowser()) return;
try {
localStorage.setItem(storageKey, JSON.stringify({ mode, variant }));
} catch (e) {
console.warn('Failed to save theme to storage:', e);
}
}
/**
* Parse HSL string to components
* Input: "47 95% 58%" -> { h: 47, s: 95, l: 58 }
*/
export function parseHSL(hsl: HSLValue): { h: number; s: number; l: number } {
const parts = hsl.split(' ');
return {
h: parseFloat(parts[0]),
s: parseFloat(parts[1]),
l: parseFloat(parts[2]),
};
}
/**
* Create HSL string from components
*/
export function createHSL(h: number, s: number, l: number): HSLValue {
return `${h} ${s}% ${l}%`;
}
/**
* Adjust lightness of an HSL color
*/
export function adjustLightness(hsl: HSLValue, amount: number): HSLValue {
const { h, s, l } = parseHSL(hsl);
const newL = Math.max(0, Math.min(100, l + amount));
return createHSL(h, s, newL);
}
/**
* Adjust saturation of an HSL color
*/
export function adjustSaturation(hsl: HSLValue, amount: number): HSLValue {
const { h, s, l } = parseHSL(hsl);
const newS = Math.max(0, Math.min(100, s + amount));
return createHSL(h, newS, l);
}
/**
* Get contrasting text color (black or white) for a background
*/
export function getContrastColor(backgroundHSL: HSLValue): HSLValue {
const { l } = parseHSL(backgroundHSL);
// Use white text for dark backgrounds, black for light
return l > 55 ? '0 0% 0%' : '0 0% 100%';
}
/**
* Generate CSS string for all theme variants
* Useful for generating static CSS files
*/
export function generateThemeCSS(
primaryOverrides?: Record<string, { light: HSLValue; dark: HSLValue }>
): string {
let css = '';
// Root (default Lume light)
const defaultColors = getThemeColors('lume', 'light', primaryOverrides?.['lume']);
const defaultVars = colorsToCssVars(defaultColors);
css += ':root {\n';
Object.entries(defaultVars).forEach(([key, value]) => {
css += ` ${key}: ${value};\n`;
});
css += '}\n\n';
// Each variant
for (const [variantName, definition] of Object.entries(THEME_DEFINITIONS)) {
const variant = variantName as ThemeVariant;
const override = primaryOverrides?.[variant];
// Light mode
const lightColors = getThemeColors(variant, 'light', override);
const lightVars = colorsToCssVars(lightColors);
css += `[data-theme="${variant}"] {\n`;
Object.entries(lightVars).forEach(([key, value]) => {
css += ` ${key}: ${value};\n`;
});
css += '}\n\n';
// Dark mode
const darkColors = getThemeColors(variant, 'dark', override);
const darkVars = colorsToCssVars(darkColors);
css += `[data-theme="${variant}"].dark,\n.dark[data-theme="${variant}"] {\n`;
Object.entries(darkVars).forEach(([key, value]) => {
css += ` ${key}: ${value};\n`;
});
css += '}\n\n';
}
return css;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

907
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff