mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41: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-auth` - Unified Auth Logic (Supabase client, token management)
|
||||
- [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-types` - Unified TypeScript Types
|
||||
- [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">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
const cleanup = theme.initialize();
|
||||
return cleanup;
|
||||
});
|
||||
</script>
|
||||
|
||||
<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;
|
||||
effectiveMode: 'light' | 'dark';
|
||||
}
|
||||
|
||||
function createThemeStore() {
|
||||
const getInitialMode = (): ThemeMode => {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem('theme-mode');
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
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();
|
||||
/**
|
||||
* ManaCore theme store instance
|
||||
*
|
||||
* - Default variant: ocean (blue)
|
||||
* - Custom primary: Indigo (#6366f1)
|
||||
* - All 4 theme variants available
|
||||
*/
|
||||
export const theme = createThemeStore({
|
||||
appId: 'manacore',
|
||||
defaultVariant: 'ocean',
|
||||
primaryColor: {
|
||||
light: '239 84% 67%', // Indigo #6366f1
|
||||
dark: '239 84% 67%',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@
|
|||
import '../app.css';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
// Initialize theme
|
||||
const cleanupTheme = theme.initialize();
|
||||
|
||||
// Setup auth state change listener
|
||||
const {
|
||||
data: { subscription }
|
||||
} = data.supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
|
|
@ -16,7 +21,10 @@
|
|||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
return () => {
|
||||
cleanupTheme();
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
});
|
||||
</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">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
const cleanup = theme.initialize();
|
||||
return cleanup;
|
||||
});
|
||||
</script>
|
||||
|
||||
<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';
|
||||
export type ThemeVariant = 'lume' | 'nature' | 'ocean' | 'stone';
|
||||
// Re-export types for convenience
|
||||
export type { ThemeMode, ThemeVariant, EffectiveMode } from '@manacore/shared-theme';
|
||||
|
||||
interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
variant: ThemeVariant;
|
||||
// The actual rendered mode (light or dark), derived from mode and system preference
|
||||
effectiveMode: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const THEME_STORAGE_KEY = 'memoro-theme';
|
||||
|
||||
// Get initial theme from localStorage or system preference
|
||||
function getInitialTheme(): ThemeState {
|
||||
if (!browser) {
|
||||
return { mode: 'system', variant: 'lume', effectiveMode: 'light' };
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
let mode: ThemeMode = 'system';
|
||||
let variant: ThemeVariant = 'lume';
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
mode = parsed.mode || 'system';
|
||||
variant = parsed.variant || 'lume';
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate effective mode
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const effectiveMode: 'light' | 'dark' =
|
||||
mode === 'system' ? (prefersDark ? 'dark' : 'light') : mode === 'dark' ? 'dark' : 'light';
|
||||
|
||||
return {
|
||||
mode,
|
||||
variant,
|
||||
effectiveMode
|
||||
};
|
||||
}
|
||||
|
||||
// Create the theme store
|
||||
function createThemeStore() {
|
||||
const { subscribe, set, update } = writable<ThemeState>(getInitialTheme());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setMode: (mode: ThemeMode) => {
|
||||
update((state) => {
|
||||
const prefersDark = browser
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false;
|
||||
const effectiveMode: 'light' | 'dark' =
|
||||
mode === 'system'
|
||||
? prefersDark
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: mode === 'dark'
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
const newState = { ...state, mode, effectiveMode };
|
||||
if (browser) {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(newState));
|
||||
applyTheme(newState);
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
setVariant: (variant: ThemeVariant) => {
|
||||
update((state) => {
|
||||
const newState = { ...state, variant };
|
||||
if (browser) {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(newState));
|
||||
applyTheme(newState);
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
toggleMode: () => {
|
||||
update((state) => {
|
||||
// Toggle between light and dark (skip system)
|
||||
const newMode: ThemeMode = state.effectiveMode === 'light' ? 'dark' : 'light';
|
||||
const newState = { ...state, mode: newMode, effectiveMode: newMode };
|
||||
if (browser) {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(newState));
|
||||
applyTheme(newState);
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
},
|
||||
initialize: () => {
|
||||
if (browser) {
|
||||
const state = getInitialTheme();
|
||||
set(state);
|
||||
applyTheme(state);
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
// Only update if mode is set to 'system'
|
||||
update((state) => {
|
||||
if (state.mode === 'system') {
|
||||
const newEffectiveMode: 'light' | 'dark' = e.matches ? 'dark' : 'light';
|
||||
const newState = { ...state, effectiveMode: newEffectiveMode };
|
||||
applyTheme(newState);
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
});
|
||||
};
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
// Cleanup function
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Apply theme to document
|
||||
function applyTheme(state: ThemeState) {
|
||||
if (!browser) return;
|
||||
|
||||
const html = document.documentElement;
|
||||
|
||||
// Apply dark mode class based on effectiveMode
|
||||
if (state.effectiveMode === 'dark') {
|
||||
html.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Apply theme variant as data attribute
|
||||
html.setAttribute('data-theme', state.variant);
|
||||
}
|
||||
|
||||
export const theme = createThemeStore();
|
||||
/**
|
||||
* Memoro theme store instance
|
||||
*
|
||||
* - Default variant: lume (gold)
|
||||
* - Custom primary: Gold (#f8d62b)
|
||||
* - All 4 theme variants available
|
||||
*/
|
||||
export const theme = createThemeStore({
|
||||
appId: 'memoro',
|
||||
defaultVariant: 'lume',
|
||||
primaryColor: {
|
||||
light: '47 95% 58%', // Gold #f8d62b
|
||||
dark: '47 95% 58%',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,45 +9,11 @@
|
|||
|
||||
let { children } = $props();
|
||||
|
||||
let currentTheme = $derived($theme);
|
||||
let isDark = $derived(currentTheme.effectiveMode === 'dark');
|
||||
|
||||
// Get page background based on theme variant
|
||||
let pageBackground = $derived(() => {
|
||||
const variant = currentTheme.variant;
|
||||
if (isDark) {
|
||||
const colors: Record<string, string> = {
|
||||
lume: '#101010',
|
||||
nature: '#121212',
|
||||
stone: '#121212',
|
||||
ocean: '#121212'
|
||||
};
|
||||
return colors[variant];
|
||||
} else {
|
||||
const colors: Record<string, string> = {
|
||||
lume: '#dddddd',
|
||||
nature: '#FBFDF8',
|
||||
stone: '#F5F7F9',
|
||||
ocean: '#F5FCFF'
|
||||
};
|
||||
return colors[variant];
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize theme on mount
|
||||
onMount(() => {
|
||||
const cleanup = theme.initialize();
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
// Update body and html background when theme changes
|
||||
$effect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const bgColor = pageBackground();
|
||||
document.documentElement.style.backgroundColor = bgColor;
|
||||
document.body.style.backgroundColor = bgColor;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@
|
|||
"maerchenzauber:dev": "turbo run dev --filter=maerchenzauber...",
|
||||
"manacore:dev": "turbo run dev --filter=manacore...",
|
||||
"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": {
|
||||
"prettier": "^3.3.3",
|
||||
|
|
|
|||
|
|
@ -1,107 +1,114 @@
|
|||
/**
|
||||
* 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:
|
||||
* ```
|
||||
* import sharedPreset from '@manacore/shared-tailwind/preset';
|
||||
*
|
||||
* import preset from '@manacore/shared-tailwind/preset';
|
||||
*
|
||||
* export default {
|
||||
* presets: [sharedPreset],
|
||||
* presets: [preset],
|
||||
* content: ['./src/**\/*.{html,js,svelte,ts}'],
|
||||
* // app-specific overrides...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { colors } from './colors.js';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const preset = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Brand colors
|
||||
mana: colors.mana,
|
||||
|
||||
// Primary scale
|
||||
primary: colors.primary,
|
||||
|
||||
// Semantic colors using CSS custom properties
|
||||
// These can be changed at runtime via themes.css
|
||||
background: 'var(--color-background)',
|
||||
foreground: 'var(--color-foreground)',
|
||||
|
||||
// Content areas
|
||||
content: {
|
||||
DEFAULT: 'var(--color-content)',
|
||||
hover: 'var(--color-content-hover)',
|
||||
page: 'var(--color-content-page)',
|
||||
// Brand color (consistent across all apps)
|
||||
mana: '#4287f5',
|
||||
|
||||
// ===== HSL-Based Semantic Colors =====
|
||||
// These use CSS variables set by @manacore/shared-theme
|
||||
// Format: hsl(var(--color-name)) where --color-name is "H S% L%"
|
||||
|
||||
// Page background
|
||||
background: 'hsl(var(--color-background))',
|
||||
|
||||
// Main text color
|
||||
foreground: 'hsl(var(--color-foreground))',
|
||||
|
||||
// Primary brand color (customizable per app)
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--color-primary))',
|
||||
foreground: 'hsl(var(--color-primary-foreground))',
|
||||
},
|
||||
|
||||
// Menu/sidebar
|
||||
menu: {
|
||||
DEFAULT: 'var(--color-menu)',
|
||||
hover: 'var(--color-menu-hover)',
|
||||
|
||||
// Secondary accent
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--color-secondary))',
|
||||
foreground: 'hsl(var(--color-secondary-foreground))',
|
||||
},
|
||||
|
||||
// Text
|
||||
theme: {
|
||||
DEFAULT: 'var(--color-text)',
|
||||
secondary: 'var(--color-text-secondary)',
|
||||
|
||||
// Card/content surfaces
|
||||
surface: {
|
||||
DEFAULT: 'hsl(var(--color-surface))',
|
||||
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
|
||||
border: {
|
||||
light: 'var(--color-border-light)',
|
||||
DEFAULT: 'var(--color-border)',
|
||||
strong: 'var(--color-border-strong)',
|
||||
DEFAULT: 'hsl(var(--color-border))',
|
||||
strong: 'hsl(var(--color-border-strong))',
|
||||
},
|
||||
|
||||
// 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))',
|
||||
},
|
||||
|
||||
// Buttons
|
||||
'primary-btn': {
|
||||
DEFAULT: 'var(--color-primary-button)',
|
||||
text: 'var(--color-primary-button-text)',
|
||||
},
|
||||
'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,
|
||||
DEFAULT: 'hsl(var(--color-primary))',
|
||||
text: 'hsl(var(--color-primary-foreground))',
|
||||
},
|
||||
},
|
||||
|
||||
// Border radius tokens
|
||||
|
||||
// Border radius tokens (CSS variable support)
|
||||
borderRadius: {
|
||||
'none': '0',
|
||||
'sm': '0.25rem',
|
||||
DEFAULT: '0.375rem',
|
||||
'md': '0.5rem',
|
||||
'lg': '0.75rem',
|
||||
'xl': '1rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '2rem',
|
||||
'sm': 'var(--radius-sm, 0.25rem)',
|
||||
DEFAULT: 'var(--radius, 0.375rem)',
|
||||
'md': 'var(--radius-md, 0.5rem)',
|
||||
'lg': 'var(--radius-lg, 0.75rem)',
|
||||
'xl': 'var(--radius-xl, 1rem)',
|
||||
'2xl': 'var(--radius-2xl, 1.5rem)',
|
||||
'3xl': 'var(--radius-3xl, 2rem)',
|
||||
'full': '9999px',
|
||||
},
|
||||
|
||||
|
||||
// Box shadow tokens
|
||||
boxShadow: {
|
||||
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
|
|
@ -113,7 +120,7 @@ const preset = {
|
|||
'inner': 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)',
|
||||
'none': 'none',
|
||||
},
|
||||
|
||||
|
||||
// Font family
|
||||
fontFamily: {
|
||||
sans: [
|
||||
|
|
@ -136,14 +143,37 @@ const preset = {
|
|||
'monospace',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
// Animation
|
||||
animation: {
|
||||
'spin-slow': 'spin 3s linear infinite',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) 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
|
||||
transitionDuration: {
|
||||
'250': '250ms',
|
||||
|
|
|
|||
|
|
@ -1,265 +1,434 @@
|
|||
/**
|
||||
* CSS Custom Properties for ManaCore themes
|
||||
*
|
||||
* Usage:
|
||||
* 1. Import in your app.css: @import '@manacore/shared-tailwind/themes.css';
|
||||
* 2. Set theme with data-theme attribute: <html data-theme="lume">
|
||||
* 3. Dark mode is automatic with .dark class or prefers-color-scheme
|
||||
* Shared Theme CSS Variables (HSL-based)
|
||||
*
|
||||
* This file defines HSL-based CSS custom properties for all theme variants.
|
||||
* Variables are set by @manacore/shared-theme's createThemeStore() at runtime,
|
||||
* but this file provides sensible defaults for static rendering.
|
||||
*
|
||||
* 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 */
|
||||
:root,
|
||||
[data-theme="lume"] {
|
||||
--color-primary: #f8d62b;
|
||||
--color-primary-button: #f8d62b;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #D4B200;
|
||||
--color-secondary-button: #FFE9A3;
|
||||
|
||||
--color-background: #dddddd;
|
||||
--color-foreground: #2c2c2c;
|
||||
--color-content: #ffffff;
|
||||
--color-content-hover: #f5f5f5;
|
||||
--color-content-page: #ffffff;
|
||||
--color-menu: #dddddd;
|
||||
--color-menu-hover: #cccccc;
|
||||
|
||||
--color-text: #2c2c2c;
|
||||
--color-text-secondary: #666666;
|
||||
|
||||
--color-border-light: #f2f2f2;
|
||||
--color-border: #e6e6e6;
|
||||
--color-border-strong: #cccccc;
|
||||
|
||||
--color-error: #e74c3c;
|
||||
--color-success: #27ae60;
|
||||
--color-warning: #f39c12;
|
||||
/* ===== Default Theme (Lume Light) ===== */
|
||||
:root {
|
||||
/* Primary brand color */
|
||||
--color-primary: 47 95% 58%;
|
||||
--color-primary-foreground: 0 0% 0%;
|
||||
|
||||
/* Secondary accent */
|
||||
--color-secondary: 47 100% 41%;
|
||||
--color-secondary-foreground: 0 0% 0%;
|
||||
|
||||
/* Page background */
|
||||
--color-background: 0 0% 87%;
|
||||
|
||||
/* Main text color */
|
||||
--color-foreground: 0 0% 17%;
|
||||
|
||||
/* Surfaces (cards, modals, etc.) */
|
||||
--color-surface: 0 0% 100%;
|
||||
--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,
|
||||
[data-theme="lume"].dark,
|
||||
[data-theme="lume"] .dark {
|
||||
--color-primary: #f8d62b;
|
||||
--color-primary-button: #7C6B16;
|
||||
--color-primary-button-text: #ffffff;
|
||||
--color-secondary: #D4B200;
|
||||
--color-secondary-button: #1E1E1E;
|
||||
|
||||
--color-background: #101010;
|
||||
--color-foreground: #ffffff;
|
||||
--color-content: #1E1E1E;
|
||||
--color-content-hover: #333333;
|
||||
--color-content-page: #121212;
|
||||
--color-menu: #101010;
|
||||
--color-menu-hover: #333333;
|
||||
|
||||
--color-text: #ffffff;
|
||||
--color-text-secondary: #a0a0a0;
|
||||
|
||||
--color-border-light: #333333;
|
||||
--color-border: #424242;
|
||||
--color-border-strong: #616161;
|
||||
|
||||
--color-error: #e74c3c;
|
||||
--color-success: #2ecc71;
|
||||
--color-warning: #f1c40f;
|
||||
:root.dark {
|
||||
--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 */
|
||||
/* ===== 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"] {
|
||||
--color-primary: #4CAF50;
|
||||
--color-primary-button: #A08500;
|
||||
--color-primary-button-text: #ffffff;
|
||||
--color-secondary: #81C784;
|
||||
--color-secondary-button: #F1F8E9;
|
||||
|
||||
--color-background: #FBFDF8;
|
||||
--color-foreground: #1B5E20;
|
||||
--color-content: #F1F8E9;
|
||||
--color-content-hover: #E8F5E9;
|
||||
--color-content-page: #ffffff;
|
||||
--color-menu: #E8F5E9;
|
||||
--color-menu-hover: #C8E6C9;
|
||||
|
||||
--color-text: #1B5E20;
|
||||
--color-text-secondary: #388E3C;
|
||||
|
||||
--color-border-light: #E8F5E9;
|
||||
--color-border: #C8E6C9;
|
||||
--color-border-strong: #A5D6A7;
|
||||
|
||||
--color-error: #E57373;
|
||||
--color-success: #66BB6A;
|
||||
--color-warning: #FFB74D;
|
||||
--color-primary: 122 39% 49%;
|
||||
--color-primary-foreground: 0 0% 100%;
|
||||
--color-secondary: 122 38% 63%;
|
||||
--color-secondary-foreground: 0 0% 0%;
|
||||
--color-background: 80 33% 97%;
|
||||
--color-foreground: 122 56% 24%;
|
||||
--color-surface: 0 0% 100%;
|
||||
--color-surface-hover: 120 25% 95%;
|
||||
--color-surface-elevated: 0 0% 100%;
|
||||
--color-muted: 120 25% 95%;
|
||||
--color-muted-foreground: 122 20% 40%;
|
||||
--color-border: 120 25% 91%;
|
||||
--color-border-strong: 120 26% 79%;
|
||||
--color-error: 0 65% 67%;
|
||||
--color-success: 122 39% 49%;
|
||||
--color-warning: 36 100% 50%;
|
||||
--color-input: 0 0% 100%;
|
||||
--color-ring: 122 39% 49%;
|
||||
}
|
||||
|
||||
[data-theme="nature"].dark,
|
||||
[data-theme="nature"] .dark {
|
||||
--color-primary: #4CAF50;
|
||||
--color-primary-button: #FF9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #81C784;
|
||||
--color-secondary-button: #1E1E1E;
|
||||
|
||||
--color-background: #121212;
|
||||
--color-foreground: #FFFFFF;
|
||||
--color-content: #1E1E1E;
|
||||
--color-content-hover: #2E7D32;
|
||||
--color-content-page: #121212;
|
||||
--color-menu: #252525;
|
||||
--color-menu-hover: #2E7D32;
|
||||
|
||||
--color-text: #FFFFFF;
|
||||
--color-text-secondary: #A5D6A7;
|
||||
|
||||
--color-border-light: #1B5E20;
|
||||
--color-border: #2E7D32;
|
||||
--color-border-strong: #388E3C;
|
||||
|
||||
--color-error: #CF6679;
|
||||
--color-success: #81C784;
|
||||
--color-warning: #FFD54F;
|
||||
.dark[data-theme="nature"] {
|
||||
--color-primary: 122 39% 49%;
|
||||
--color-primary-foreground: 0 0% 100%;
|
||||
--color-secondary: 122 30% 35%;
|
||||
--color-secondary-foreground: 0 0% 100%;
|
||||
--color-background: 0 0% 7%;
|
||||
--color-foreground: 0 0% 100%;
|
||||
--color-surface: 120 10% 12%;
|
||||
--color-surface-hover: 120 10% 16%;
|
||||
--color-surface-elevated: 120 10% 14%;
|
||||
--color-muted: 120 10% 20%;
|
||||
--color-muted-foreground: 120 10% 60%;
|
||||
--color-border: 120 10% 25%;
|
||||
--color-border-strong: 120 10% 35%;
|
||||
--color-error: 0 65% 57%;
|
||||
--color-success: 122 50% 55%;
|
||||
--color-warning: 48 100% 50%;
|
||||
--color-input: 120 10% 14%;
|
||||
--color-ring: 122 39% 49%;
|
||||
}
|
||||
|
||||
/* Stone Theme */
|
||||
/* ===== Stone Theme (Blue Gray) ===== */
|
||||
[data-theme="stone"] {
|
||||
--color-primary: #607D8B;
|
||||
--color-primary-button: #FF9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #90A4AE;
|
||||
--color-secondary-button: #ECEFF1;
|
||||
|
||||
--color-background: #F5F7F9;
|
||||
--color-foreground: #263238;
|
||||
--color-content: #ECEFF1;
|
||||
--color-content-hover: #E0E6EA;
|
||||
--color-content-page: #ffffff;
|
||||
--color-menu: #E0E6EA;
|
||||
--color-menu-hover: #CFD8DC;
|
||||
|
||||
--color-text: #263238;
|
||||
--color-text-secondary: #546E7A;
|
||||
|
||||
--color-border-light: #ECEFF1;
|
||||
--color-border: #CFD8DC;
|
||||
--color-border-strong: #B0BEC5;
|
||||
|
||||
--color-error: #EF5350;
|
||||
--color-success: #66BB6A;
|
||||
--color-warning: #FFA726;
|
||||
--color-primary: 200 18% 46%;
|
||||
--color-primary-foreground: 0 0% 100%;
|
||||
--color-secondary: 200 15% 62%;
|
||||
--color-secondary-foreground: 0 0% 0%;
|
||||
--color-background: 210 17% 97%;
|
||||
--color-foreground: 200 19% 18%;
|
||||
--color-surface: 0 0% 100%;
|
||||
--color-surface-hover: 200 10% 94%;
|
||||
--color-surface-elevated: 0 0% 100%;
|
||||
--color-muted: 200 10% 94%;
|
||||
--color-muted-foreground: 200 10% 45%;
|
||||
--color-border: 200 10% 88%;
|
||||
--color-border-strong: 200 12% 75%;
|
||||
--color-error: 4 90% 63%;
|
||||
--color-success: 145 63% 42%;
|
||||
--color-warning: 36 100% 50%;
|
||||
--color-input: 0 0% 100%;
|
||||
--color-ring: 200 18% 46%;
|
||||
}
|
||||
|
||||
[data-theme="stone"].dark,
|
||||
[data-theme="stone"] .dark {
|
||||
--color-primary: #78909C;
|
||||
--color-primary-button: #FF9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #90A4AE;
|
||||
--color-secondary-button: #1E1E1E;
|
||||
|
||||
--color-background: #121212;
|
||||
--color-foreground: #FFFFFF;
|
||||
--color-content: #1E1E1E;
|
||||
--color-content-hover: #37474F;
|
||||
--color-content-page: #121212;
|
||||
--color-menu: #252525;
|
||||
--color-menu-hover: #37474F;
|
||||
|
||||
--color-text: #FFFFFF;
|
||||
--color-text-secondary: #B0BEC5;
|
||||
|
||||
--color-border-light: #37474F;
|
||||
--color-border: #455A64;
|
||||
--color-border-strong: #546E7A;
|
||||
|
||||
--color-error: #CF6679;
|
||||
--color-success: #81C784;
|
||||
--color-warning: #FFD54F;
|
||||
.dark[data-theme="stone"] {
|
||||
--color-primary: 200 15% 52%;
|
||||
--color-primary-foreground: 0 0% 0%;
|
||||
--color-secondary: 200 12% 35%;
|
||||
--color-secondary-foreground: 0 0% 100%;
|
||||
--color-background: 0 0% 7%;
|
||||
--color-foreground: 0 0% 100%;
|
||||
--color-surface: 200 10% 12%;
|
||||
--color-surface-hover: 200 10% 16%;
|
||||
--color-surface-elevated: 200 10% 14%;
|
||||
--color-muted: 200 10% 20%;
|
||||
--color-muted-foreground: 200 10% 60%;
|
||||
--color-border: 200 10% 25%;
|
||||
--color-border-strong: 200 10% 35%;
|
||||
--color-error: 4 90% 58%;
|
||||
--color-success: 145 63% 49%;
|
||||
--color-warning: 48 100% 50%;
|
||||
--color-input: 200 10% 14%;
|
||||
--color-ring: 200 15% 52%;
|
||||
}
|
||||
|
||||
/* Ocean Theme */
|
||||
/* ===== Ocean Theme (Blue) ===== */
|
||||
[data-theme="ocean"] {
|
||||
--color-primary: #039BE5;
|
||||
--color-primary-button: #FF9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #4FC3F7;
|
||||
--color-secondary-button: #E1F5FE;
|
||||
|
||||
--color-background: #F5FCFF;
|
||||
--color-foreground: #01579B;
|
||||
--color-content: #E1F5FE;
|
||||
--color-content-hover: #B3E5FC;
|
||||
--color-content-page: #ffffff;
|
||||
--color-menu: #E1F5FE;
|
||||
--color-menu-hover: #B3E5FC;
|
||||
|
||||
--color-text: #01579B;
|
||||
--color-text-secondary: #0277BD;
|
||||
|
||||
--color-border-light: #E1F5FE;
|
||||
--color-border: #B3E5FC;
|
||||
--color-border-strong: #81D4FA;
|
||||
|
||||
--color-error: #EF5350;
|
||||
--color-success: #66BB6A;
|
||||
--color-warning: #FFA726;
|
||||
--color-primary: 199 98% 45%;
|
||||
--color-primary-foreground: 0 0% 100%;
|
||||
--color-secondary: 199 92% 64%;
|
||||
--color-secondary-foreground: 0 0% 0%;
|
||||
--color-background: 199 100% 97%;
|
||||
--color-foreground: 199 100% 18%;
|
||||
--color-surface: 0 0% 100%;
|
||||
--color-surface-hover: 199 100% 94%;
|
||||
--color-surface-elevated: 0 0% 100%;
|
||||
--color-muted: 199 100% 94%;
|
||||
--color-muted-foreground: 199 50% 40%;
|
||||
--color-border: 199 71% 87%;
|
||||
--color-border-strong: 199 79% 76%;
|
||||
--color-error: 4 90% 63%;
|
||||
--color-success: 145 63% 42%;
|
||||
--color-warning: 36 100% 50%;
|
||||
--color-input: 0 0% 100%;
|
||||
--color-ring: 199 98% 45%;
|
||||
}
|
||||
|
||||
[data-theme="ocean"].dark,
|
||||
[data-theme="ocean"] .dark {
|
||||
--color-primary: #039BE5;
|
||||
--color-primary-button: #FF9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #4FC3F7;
|
||||
--color-secondary-button: #1E1E1E;
|
||||
|
||||
--color-background: #121212;
|
||||
--color-foreground: #FFFFFF;
|
||||
--color-content: #1E1E1E;
|
||||
--color-content-hover: #0277BD;
|
||||
--color-content-page: #121212;
|
||||
--color-menu: #252525;
|
||||
--color-menu-hover: #0277BD;
|
||||
|
||||
--color-text: #FFFFFF;
|
||||
--color-text-secondary: #81D4FA;
|
||||
|
||||
--color-border-light: #01579B;
|
||||
--color-border: #0277BD;
|
||||
--color-border-strong: #0288D1;
|
||||
|
||||
--color-error: #CF6679;
|
||||
--color-success: #81C784;
|
||||
--color-warning: #FFD54F;
|
||||
.dark[data-theme="ocean"] {
|
||||
--color-primary: 199 98% 48%;
|
||||
--color-primary-foreground: 0 0% 0%;
|
||||
--color-secondary: 199 60% 35%;
|
||||
--color-secondary-foreground: 0 0% 100%;
|
||||
--color-background: 0 0% 7%;
|
||||
--color-foreground: 0 0% 100%;
|
||||
--color-surface: 199 30% 12%;
|
||||
--color-surface-hover: 199 30% 16%;
|
||||
--color-surface-elevated: 199 30% 14%;
|
||||
--color-muted: 199 20% 20%;
|
||||
--color-muted-foreground: 199 20% 60%;
|
||||
--color-border: 199 20% 25%;
|
||||
--color-border-strong: 199 20% 35%;
|
||||
--color-error: 4 90% 58%;
|
||||
--color-success: 145 63% 49%;
|
||||
--color-warning: 48 100% 50%;
|
||||
--color-input: 199 30% 14%;
|
||||
--color-ring: 199 98% 48%;
|
||||
}
|
||||
|
||||
/* Dark mode via media query */
|
||||
/* ===== Dark mode via media query (fallback) ===== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
--color-primary: #f8d62b;
|
||||
--color-primary-button: #7C6B16;
|
||||
--color-primary-button-text: #ffffff;
|
||||
--color-secondary: #D4B200;
|
||||
--color-secondary-button: #1E1E1E;
|
||||
|
||||
--color-background: #101010;
|
||||
--color-foreground: #ffffff;
|
||||
--color-content: #1E1E1E;
|
||||
--color-content-hover: #333333;
|
||||
--color-content-page: #121212;
|
||||
--color-menu: #101010;
|
||||
--color-menu-hover: #333333;
|
||||
|
||||
--color-text: #ffffff;
|
||||
--color-text-secondary: #a0a0a0;
|
||||
|
||||
--color-border-light: #333333;
|
||||
--color-border: #424242;
|
||||
--color-border-strong: #616161;
|
||||
|
||||
--color-error: #e74c3c;
|
||||
--color-success: #2ecc71;
|
||||
--color-warning: #f1c40f;
|
||||
:root:not(.dark):not([data-theme]) {
|
||||
--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%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Base Styles ===== */
|
||||
@layer base {
|
||||
* {
|
||||
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