feat(theme): add ThemePage components and distinct background colors

- Add unique background colors for each theme variant:
  - Lume: warm cream/gold tint
  - Nature: green tint in dark mode
  - Stone: blue-gray tint in dark mode
  - Ocean: blue tint in dark mode

- Create shared-theme-ui components:
  - ThemeColorPreview: color circles preview component
  - ThemeCard: individual theme card with status support
  - ThemeGrid: responsive grid layout
  - ThemePage: full page component with mode selector

- Integrate theme page in Chat app:
  - Add /themes route with ThemePage component
  - Add "🎨 Alle Themes" link to PillNavigation dropdown
  - Add palette icon to shared-ui icon set

- Migrate Presi and Picture apps to shared-theme system
- Update semantic color usage across all apps

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 09:03:20 +01:00
parent 129692812b
commit 54383bf7c2
92 changed files with 1793 additions and 1936 deletions

View file

@ -0,0 +1,136 @@
<script lang="ts">
import type { ThemeVariant } from '@manacore/shared-theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { Check, Lock, Clock, Star } from '@manacore/shared-icons';
import type { ThemeStatus } from '../types';
import ThemeColorPreview from './ThemeColorPreview.svelte';
interface Props {
variant: ThemeVariant;
isActive: boolean;
status?: ThemeStatus;
onClick?: () => void;
onUnlock?: () => void;
translations?: {
locked?: string;
comingSoon?: string;
premium?: string;
unlock?: string;
lightPreview?: string;
darkPreview?: string;
};
}
let {
variant,
isActive,
status = 'available',
onClick,
onUnlock,
translations = {},
}: Props = $props();
const t = {
locked: translations.locked ?? 'Gesperrt',
comingSoon: translations.comingSoon ?? 'Bald verfügbar',
premium: translations.premium ?? 'Premium',
unlock: translations.unlock ?? 'Freischalten',
lightPreview: translations.lightPreview ?? 'Hell',
darkPreview: translations.darkPreview ?? 'Dunkel',
};
const definition = $derived(THEME_DEFINITIONS[variant]);
const isAvailable = $derived(status === 'available');
const isLocked = $derived(status === 'locked');
const isComingSoon = $derived(status === 'coming_soon');
const isPremium = $derived(status === 'premium');
function handleClick() {
if (isAvailable && onClick) {
onClick();
}
}
function handleUnlock(e: MouseEvent) {
e.stopPropagation();
if (onUnlock) {
onUnlock();
}
}
</script>
<button
type="button"
onclick={handleClick}
disabled={!isAvailable}
class="relative w-full p-4 rounded-xl border-2 transition-all text-left
{isActive
? 'border-primary bg-primary/5 ring-2 ring-primary/20'
: 'border-border bg-surface hover:border-primary/50'}
{!isAvailable ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
{isPremium ? 'border-yellow-500/50' : ''}"
>
<!-- Premium badge -->
{#if isPremium}
<div
class="absolute -top-2 -right-2 flex items-center gap-1 px-2 py-0.5
bg-yellow-500 text-yellow-950 text-xs font-medium rounded-full"
>
<Star size={12} weight="fill" />
{t.premium}
</div>
{/if}
<!-- Active checkmark -->
{#if isActive && isAvailable}
<div
class="absolute top-3 right-3 w-6 h-6 flex items-center justify-center
bg-primary text-primary-foreground rounded-full"
>
<Check size={14} weight="bold" />
</div>
{/if}
<!-- Header -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xl">{definition.emoji}</span>
<span class="font-semibold text-foreground">{definition.label}</span>
</div>
<!-- Color previews -->
<div class="space-y-2 mb-3">
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-10">{t.lightPreview}</span>
<ThemeColorPreview {variant} mode="light" size="sm" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-10">{t.darkPreview}</span>
<ThemeColorPreview {variant} mode="dark" size="sm" />
</div>
</div>
<!-- Status badges -->
{#if isLocked}
<div class="flex items-center justify-between">
<div class="flex items-center gap-1 text-muted-foreground text-sm">
<Lock size={14} weight="bold" />
{t.locked}
</div>
{#if onUnlock}
<button
type="button"
onclick={handleUnlock}
class="px-2 py-1 text-xs font-medium text-primary bg-primary/10
rounded hover:bg-primary/20 transition-colors"
>
{t.unlock}
</button>
{/if}
</div>
{:else if isComingSoon}
<div class="flex items-center gap-1 text-muted-foreground text-sm">
<Clock size={14} weight="bold" />
{t.comingSoon}
</div>
{/if}
</button>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import type { ThemeVariant } from '@manacore/shared-theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
interface Props {
variant: ThemeVariant;
mode: 'light' | 'dark';
size?: 'sm' | 'md' | 'lg';
}
let { variant, mode, size = 'md' }: Props = $props();
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
};
const gapClasses = {
sm: '-ml-1.5',
md: '-ml-2',
lg: '-ml-2.5',
};
const colors = $derived(() => {
const def = THEME_DEFINITIONS[variant];
const themeColors = mode === 'dark' ? def.dark : def.light;
return [
{ name: 'primary', value: themeColors.primary },
{ name: 'secondary', value: themeColors.secondary },
{ name: 'background', value: themeColors.background },
{ name: 'surface', value: themeColors.surface },
{ name: 'muted', value: themeColors.muted },
];
});
</script>
<div class="flex items-center">
{#each colors() as color, i}
<div
class="{sizeClasses[size]} rounded-full border border-white/20 shadow-sm {i > 0
? gapClasses[size]
: ''}"
style="background-color: hsl({color.value}); z-index: {5 - i};"
title={color.name}
></div>
{/each}
</div>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import type { ThemeVariant } from '@manacore/shared-theme';
import { THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeCardData, ThemePageTranslations } from '../types';
import ThemeCard from './ThemeCard.svelte';
interface Props {
currentVariant: ThemeVariant;
onSelect: (variant: ThemeVariant) => void;
themes?: ThemeCardData[];
onUnlock?: (variant: ThemeVariant) => void;
showLockedThemes?: boolean;
translations?: Partial<ThemePageTranslations>;
}
let {
currentVariant,
onSelect,
themes,
onUnlock,
showLockedThemes = true,
translations = {},
}: Props = $props();
// Build theme data - use provided themes or create defaults from THEME_VARIANTS
const themeData = $derived(() => {
if (themes) {
return showLockedThemes ? themes : themes.filter((t) => t.status === 'available');
}
// Default: all variants are available
return THEME_VARIANTS.map((variant) => ({
variant,
status: 'available' as const,
}));
});
const cardTranslations = $derived({
locked: translations.locked,
comingSoon: translations.comingSoon,
premium: translations.premium,
unlock: translations.unlock,
lightPreview: translations.lightPreview,
darkPreview: translations.darkPreview,
});
</script>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{#each themeData() as theme (theme.variant)}
<ThemeCard
variant={theme.variant}
isActive={currentVariant === theme.variant}
status={theme.status}
onClick={() => onSelect(theme.variant)}
onUnlock={onUnlock ? () => onUnlock(theme.variant) : undefined}
translations={cardTranslations}
/>
{/each}
</div>

View file

@ -1,4 +1,21 @@
// Theme UI Components
// Theme UI Components (existing)
export { default as ThemeToggle } from './ThemeToggle.svelte';
export { default as ThemeSelector } from './ThemeSelector.svelte';
export { default as ThemeModeSelector } from './ThemeModeSelector.svelte';
// New Components
export { default as ThemeColorPreview } from './components/ThemeColorPreview.svelte';
export { default as ThemeCard } from './components/ThemeCard.svelte';
export { default as ThemeGrid } from './components/ThemeGrid.svelte';
// Pages
export { default as ThemePage } from './pages/ThemePage.svelte';
// Types
export type {
ThemeStatus,
ThemeCardData,
ThemePageProps,
ThemePageTranslations,
} from './types';
export { defaultTranslations } from './types';

View file

@ -0,0 +1,125 @@
<script lang="ts">
import type { ThemeVariant, ThemeMode } from '@manacore/shared-theme';
import { ArrowLeft, Sun, Moon, Desktop } from '@manacore/shared-icons';
import type { ThemeCardData, ThemePageTranslations } from '../types';
import { defaultTranslations } from '../types';
import ThemeGrid from '../components/ThemeGrid.svelte';
interface Props {
// Theme Store Integration
currentVariant: ThemeVariant;
onSelectTheme: (variant: ThemeVariant) => void;
// Theme Data (for store extension)
themes?: ThemeCardData[];
// UI Customization
title?: string;
subtitle?: string;
showModeSelector?: boolean;
currentMode?: ThemeMode;
onModeChange?: (mode: ThemeMode) => void;
// Back navigation
showBackButton?: boolean;
onBack?: () => void;
// Store Features (preparation)
showLockedThemes?: boolean;
onUnlockTheme?: (variant: ThemeVariant) => void;
// Translations
translations?: Partial<ThemePageTranslations>;
}
let {
currentVariant,
onSelectTheme,
themes,
title,
subtitle,
showModeSelector = false,
currentMode = 'system',
onModeChange,
showBackButton = false,
onBack,
showLockedThemes = true,
onUnlockTheme,
translations = {},
}: Props = $props();
// Merge translations with defaults
const t = $derived({ ...defaultTranslations, ...translations });
const modes: { mode: ThemeMode; icon: typeof Sun; label: string }[] = $derived([
{ mode: 'light', icon: Sun, label: t.lightMode },
{ mode: 'dark', icon: Moon, label: t.darkMode },
{ mode: 'system', icon: Desktop, label: t.systemMode },
]);
</script>
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- Header -->
<header class="mb-8">
<div class="flex items-center gap-3 mb-2">
{#if showBackButton && onBack}
<button
type="button"
onclick={onBack}
class="p-2 -ml-2 text-muted-foreground hover:text-foreground
hover:bg-muted rounded-lg transition-colors"
aria-label="Zurück"
>
<ArrowLeft size={20} weight="bold" />
</button>
{/if}
<h1 class="text-2xl font-bold text-foreground">
{title ?? t.title}
</h1>
</div>
<p class="text-muted-foreground">
{subtitle ?? t.subtitle}
</p>
</header>
<!-- Mode Selector -->
{#if showModeSelector && onModeChange}
<section class="mb-8">
<h2 class="text-sm font-medium text-muted-foreground mb-3">
{t.modeLabel}
</h2>
<div class="inline-flex rounded-lg bg-muted p-1">
{#each modes as { mode, icon: Icon, label }}
<button
type="button"
onclick={() => onModeChange(mode)}
class="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors
{currentMode === mode
? 'bg-surface text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
<Icon size={16} weight={currentMode === mode ? 'fill' : 'regular'} />
{label}
</button>
{/each}
</div>
</section>
{/if}
<!-- Theme Grid -->
<section>
<h2 class="text-sm font-medium text-muted-foreground mb-4">
{t.currentTheme}
</h2>
<ThemeGrid
{currentVariant}
onSelect={onSelectTheme}
{themes}
onUnlock={onUnlockTheme}
{showLockedThemes}
{translations}
/>
</section>
</div>
</div>

View file

@ -0,0 +1,87 @@
import type { ThemeVariant, ThemeMode } from '@manacore/shared-theme';
/**
* Theme availability status for store integration
*/
export type ThemeStatus = 'available' | 'locked' | 'coming_soon' | 'premium';
/**
* Theme card data for displaying in grid/list
*/
export interface ThemeCardData {
variant: ThemeVariant;
status: ThemeStatus;
isPremium?: boolean;
price?: number;
releaseDate?: string;
}
/**
* Props for ThemePage component
*/
export interface ThemePageProps {
// Theme Store Integration
currentVariant: ThemeVariant;
onSelectTheme: (variant: ThemeVariant) => void;
// Theme Data (for store extension)
themes?: ThemeCardData[];
// UI Customization
title?: string;
subtitle?: string;
showModeSelector?: boolean;
currentMode?: ThemeMode;
onModeChange?: (mode: ThemeMode) => void;
// Back navigation
showBackButton?: boolean;
onBack?: () => void;
// Store Features (preparation)
showLockedThemes?: boolean;
onUnlockTheme?: (variant: ThemeVariant) => void;
// Translations
translations?: Partial<ThemePageTranslations>;
}
/**
* Translations for ThemePage
*/
export interface ThemePageTranslations {
title: string;
subtitle: string;
modeLabel: string;
lightMode: string;
darkMode: string;
systemMode: string;
currentTheme: string;
selectTheme: string;
locked: string;
comingSoon: string;
premium: string;
unlock: string;
lightPreview: string;
darkPreview: string;
}
/**
* Default German translations
*/
export const defaultTranslations: ThemePageTranslations = {
title: 'Theme-Einstellungen',
subtitle: 'Wähle dein bevorzugtes Farbschema',
modeLabel: 'Modus',
lightMode: 'Hell',
darkMode: 'Dunkel',
systemMode: 'System',
currentTheme: 'Aktuelles Theme',
selectTheme: 'Auswählen',
locked: 'Gesperrt',
comingSoon: 'Bald verfügbar',
premium: 'Premium',
unlock: 'Freischalten',
lightPreview: 'Hell',
darkPreview: 'Dunkel',
};