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
|
|
@ -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
|
||||||
|
|
|
||||||
25
maerchenzauber/apps/web/src/lib/stores/theme.ts
Normal file
25
maerchenzauber/apps/web/src/lib/stores/theme.ts
Normal 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%',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
25
manadeck/apps/web/src/lib/stores/theme.ts
Normal file
25
manadeck/apps/web/src/lib/stores/theme.ts
Normal 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%',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
// 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();
|
|
||||||
|
|
|
||||||
|
|
@ -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?.()}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
170
packages/shared-theme-ui/README.md
Normal file
170
packages/shared-theme-ui/README.md
Normal 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
|
||||||
25
packages/shared-theme-ui/package.json
Normal file
25
packages/shared-theme-ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
packages/shared-theme-ui/src/ThemeModeSelector.svelte
Normal file
87
packages/shared-theme-ui/src/ThemeModeSelector.svelte
Normal 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>
|
||||||
125
packages/shared-theme-ui/src/ThemeSelector.svelte
Normal file
125
packages/shared-theme-ui/src/ThemeSelector.svelte
Normal 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>
|
||||||
74
packages/shared-theme-ui/src/ThemeToggle.svelte
Normal file
74
packages/shared-theme-ui/src/ThemeToggle.svelte
Normal 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>
|
||||||
4
packages/shared-theme-ui/src/index.ts
Normal file
4
packages/shared-theme-ui/src/index.ts
Normal 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';
|
||||||
15
packages/shared-theme-ui/tsconfig.json
Normal file
15
packages/shared-theme-ui/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
225
packages/shared-theme/README.md
Normal file
225
packages/shared-theme/README.md
Normal 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
|
||||||
22
packages/shared-theme/package.json
Normal file
22
packages/shared-theme/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
244
packages/shared-theme/src/constants.ts
Normal file
244
packages/shared-theme/src/constants.ts
Normal 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;
|
||||||
43
packages/shared-theme/src/index.ts
Normal file
43
packages/shared-theme/src/index.ts
Normal 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';
|
||||||
227
packages/shared-theme/src/store.svelte.ts
Normal file
227
packages/shared-theme/src/store.svelte.ts
Normal 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;
|
||||||
133
packages/shared-theme/src/types.ts
Normal file
133
packages/shared-theme/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
245
packages/shared-theme/src/utils.ts
Normal file
245
packages/shared-theme/src/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
16
packages/shared-theme/tsconfig.json
Normal file
16
packages/shared-theme/tsconfig.json
Normal 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
907
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue