Fix guest PWA dark mode contrast
This commit is contained in:
@@ -104,9 +104,9 @@ export default function EmotionPicker({
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">
|
||||
{headingTitle}
|
||||
{headingSubtitle && <span className="ml-2 text-xs text-muted-foreground">{headingSubtitle}</span>}
|
||||
{headingSubtitle && <span className="ml-2 text-xs text-muted-foreground dark:text-white/70">{headingSubtitle}</span>}
|
||||
</h3>
|
||||
{loading && <span className="text-xs text-muted-foreground">Lade Emotionen…</span>}
|
||||
{loading && <span className="text-xs text-muted-foreground dark:text-white/70">Lade Emotionen…</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -146,12 +146,12 @@ export default function EmotionPicker({
|
||||
{emotion.emoji}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-foreground">{localizedName}</div>
|
||||
<div className="font-medium text-sm text-foreground dark:text-white">{localizedName}</div>
|
||||
{localizedDescription && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">{localizedDescription}</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-2 dark:text-white/60">{localizedDescription}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100" />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100 dark:text-white/60" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function FiltersBar({
|
||||
'inline-flex items-center gap-1 rounded-full px-3 py-1.5 transition',
|
||||
isActive
|
||||
? 'bg-pink-500 text-white shadow'
|
||||
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600',
|
||||
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
{React.cloneElement(filter.icon as React.ReactElement, { className: 'h-3.5 w-3.5' })}
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function GalleryPreview({ token }: Props) {
|
||||
'inline-flex items-center rounded-full px-3 py-1.5 transition',
|
||||
isActive
|
||||
? 'bg-pink-500 text-white shadow'
|
||||
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600',
|
||||
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600 dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
<span className="whitespace-nowrap">{filter.label}</span>
|
||||
|
||||
@@ -40,6 +40,10 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = {
|
||||
const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase();
|
||||
const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase();
|
||||
const DEFAULT_BACKGROUND = DEFAULT_EVENT_BRANDING.backgroundColor.toLowerCase();
|
||||
const LIGHT_LUMINANCE_THRESHOLD = 0.65;
|
||||
const DARK_LUMINANCE_THRESHOLD = 0.35;
|
||||
const DARK_FALLBACK_SURFACE = '#0f172a';
|
||||
const LIGHT_FALLBACK_SURFACE = '#ffffff';
|
||||
const FONT_SCALE_MAP: Record<'s' | 'm' | 'l', number> = {
|
||||
s: 0.94,
|
||||
m: 1,
|
||||
@@ -110,22 +114,69 @@ function resolveBranding(input?: EventBranding | null): EventBranding {
|
||||
};
|
||||
}
|
||||
|
||||
function applyCssVariables(branding: EventBranding) {
|
||||
type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
function resolveThemeVariant(
|
||||
mode: EventBranding['mode'],
|
||||
backgroundColor: string,
|
||||
appearanceOverride: 'light' | 'dark' | null,
|
||||
): ThemeVariant {
|
||||
const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false;
|
||||
const backgroundLuminance = relativeLuminance(backgroundColor || DEFAULT_EVENT_BRANDING.backgroundColor);
|
||||
const backgroundPrefers = backgroundLuminance >= LIGHT_LUMINANCE_THRESHOLD
|
||||
? 'light'
|
||||
: backgroundLuminance <= DARK_LUMINANCE_THRESHOLD
|
||||
? 'dark'
|
||||
: null;
|
||||
|
||||
if (mode === 'dark') {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
if (mode === 'light') {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
if (appearanceOverride) {
|
||||
return appearanceOverride;
|
||||
}
|
||||
|
||||
if (backgroundPrefers) {
|
||||
return backgroundPrefers;
|
||||
}
|
||||
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function clampToTheme(color: string, theme: ThemeVariant): string {
|
||||
const luminance = relativeLuminance(color);
|
||||
if (theme === 'dark' && luminance >= LIGHT_LUMINANCE_THRESHOLD) {
|
||||
return DARK_FALLBACK_SURFACE;
|
||||
}
|
||||
if (theme === 'light' && luminance <= DARK_LUMINANCE_THRESHOLD) {
|
||||
return LIGHT_FALLBACK_SURFACE;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function applyCssVariables(branding: EventBranding, theme: ThemeVariant) {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const background = branding.backgroundColor;
|
||||
const surfaceCandidate = branding.palette?.surface ?? background;
|
||||
const background = clampToTheme(branding.backgroundColor, theme);
|
||||
const surfaceCandidate = clampToTheme(branding.palette?.surface ?? background, theme);
|
||||
const backgroundLuminance = relativeLuminance(background);
|
||||
const surfaceLuminance = relativeLuminance(surfaceCandidate);
|
||||
const surface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06
|
||||
? backgroundLuminance >= 0.6
|
||||
? '#ffffff'
|
||||
: '#0f172a'
|
||||
? theme === 'light'
|
||||
? LIGHT_FALLBACK_SURFACE
|
||||
: DARK_FALLBACK_SURFACE
|
||||
: surfaceCandidate;
|
||||
const isLight = backgroundLuminance >= 0.6;
|
||||
const isLight = theme === 'light';
|
||||
const foreground = isLight ? '#1f2937' : '#f8fafc';
|
||||
const mutedForeground = isLight ? '#6b7280' : '#cbd5e1';
|
||||
const muted = isLight ? '#f6efec' : '#1f2937';
|
||||
@@ -213,66 +264,22 @@ function applyThemeMode(
|
||||
mode: EventBranding['mode'],
|
||||
backgroundColor: string,
|
||||
appearanceOverride: 'light' | 'dark' | null,
|
||||
) {
|
||||
): ThemeVariant {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
return 'light';
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false;
|
||||
const backgroundLuminance = relativeLuminance(backgroundColor || DEFAULT_EVENT_BRANDING.backgroundColor);
|
||||
const backgroundPrefers = backgroundLuminance >= 0.65
|
||||
? 'light'
|
||||
: backgroundLuminance <= 0.35
|
||||
? 'dark'
|
||||
: null;
|
||||
const applyDark = () => root.classList.add('dark');
|
||||
const applyLight = () => root.classList.remove('dark');
|
||||
|
||||
if (mode === 'dark') {
|
||||
applyDark();
|
||||
const theme = resolveThemeVariant(mode, backgroundColor, appearanceOverride);
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
root.style.colorScheme = 'dark';
|
||||
return;
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
if (mode === 'light') {
|
||||
applyLight();
|
||||
root.style.colorScheme = 'light';
|
||||
return;
|
||||
}
|
||||
|
||||
if (appearanceOverride) {
|
||||
if (appearanceOverride === 'dark') {
|
||||
applyDark();
|
||||
root.style.colorScheme = 'dark';
|
||||
} else {
|
||||
applyLight();
|
||||
root.style.colorScheme = 'light';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (backgroundPrefers) {
|
||||
if (backgroundPrefers === 'dark') {
|
||||
applyDark();
|
||||
root.style.colorScheme = 'dark';
|
||||
} else {
|
||||
applyLight();
|
||||
root.style.colorScheme = 'light';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (prefersDark) {
|
||||
applyDark();
|
||||
root.style.colorScheme = 'dark';
|
||||
return;
|
||||
}
|
||||
|
||||
applyLight();
|
||||
root.classList.remove('dark');
|
||||
root.style.colorScheme = 'light';
|
||||
return 'light';
|
||||
}
|
||||
|
||||
export function EventBrandingProvider({
|
||||
@@ -290,9 +297,9 @@ export function EventBrandingProvider({
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.add('guest-theme');
|
||||
}
|
||||
applyCssVariables(resolved);
|
||||
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
|
||||
applyThemeMode(resolved.mode ?? 'auto', resolved.backgroundColor, appearanceOverride);
|
||||
const theme = applyThemeMode(resolved.mode ?? 'auto', resolved.backgroundColor, appearanceOverride);
|
||||
applyCssVariables(resolved, theme);
|
||||
|
||||
return () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -304,8 +311,12 @@ export function EventBrandingProvider({
|
||||
document.documentElement.classList.remove('guest-theme');
|
||||
}
|
||||
resetCssVariables();
|
||||
applyCssVariables(DEFAULT_EVENT_BRANDING);
|
||||
applyThemeMode(DEFAULT_EVENT_BRANDING.mode ?? 'auto', DEFAULT_EVENT_BRANDING.backgroundColor, appearanceOverride);
|
||||
const fallbackTheme = applyThemeMode(
|
||||
DEFAULT_EVENT_BRANDING.mode ?? 'auto',
|
||||
DEFAULT_EVENT_BRANDING.backgroundColor,
|
||||
appearanceOverride,
|
||||
);
|
||||
applyCssVariables(DEFAULT_EVENT_BRANDING, fallbackTheme);
|
||||
};
|
||||
}, [appearanceOverride, resolved]);
|
||||
|
||||
|
||||
@@ -38,8 +38,9 @@ describe('EventBrandingProvider', () => {
|
||||
expect(document.documentElement.classList.contains('guest-theme')).toBe(true);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe(sampleBranding.backgroundColor);
|
||||
expect(document.documentElement.style.getPropertyValue('--guest-background')).toBe('#0f172a');
|
||||
expect(document.documentElement.style.getPropertyValue('--guest-font-scale')).toBe('1.08');
|
||||
expect(document.documentElement.style.getPropertyValue('--foreground')).toBe('#f8fafc');
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -620,7 +620,7 @@ export function MissionActionCard({
|
||||
const isExpanded = card ? expandedTaskId === card.id : false;
|
||||
const isExpandable = Boolean(card && expandableTitles[card.id]);
|
||||
const titleClamp = isExpanded ? '' : 'line-clamp-2 sm:line-clamp-3';
|
||||
const titleClasses = `text-xl font-semibold leading-snug text-slate-900 sm:text-2xl break-words py-1 min-h-[3.75rem] sm:min-h-[4.5rem] ${titleClamp}`;
|
||||
const titleClasses = `text-xl font-semibold leading-snug text-slate-900 dark:text-white sm:text-2xl break-words py-1 min-h-[3.75rem] sm:min-h-[4.5rem] ${titleClamp}`;
|
||||
const titleId = card ? `task-title-${card.id}` : undefined;
|
||||
const toggleExpanded = () => {
|
||||
if (!card) return;
|
||||
@@ -648,7 +648,7 @@ export function MissionActionCard({
|
||||
<div className="absolute -left-12 -top-10 h-32 w-32 rounded-full bg-white/40 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-28 w-28 rounded-full bg-white/25 blur-3xl" />
|
||||
|
||||
<div className="relative z-10 m-3 rounded-2xl border border-white/35 bg-white/80 px-4 py-4 shadow-lg backdrop-blur-xl">
|
||||
<div className="relative z-10 m-3 rounded-2xl border border-white/35 bg-white/80 px-4 py-4 shadow-lg backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/70">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
@@ -664,15 +664,15 @@ export function MissionActionCard({
|
||||
>
|
||||
{card?.emotion?.name ?? 'Fotoaufgabe'}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-slate-600">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-white/70">
|
||||
<Sparkles className="h-4 w-4 text-amber-500" aria-hidden />
|
||||
<span>Foto-Challenge</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-1 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm sm:flex">
|
||||
<Timer className="mr-1 h-3.5 w-3.5 text-slate-500" aria-hidden />
|
||||
<div className="hidden items-center gap-1 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-slate-950/70 dark:text-white/80 sm:flex">
|
||||
<Timer className="mr-1 h-3.5 w-3.5 text-slate-500 dark:text-white/60" aria-hidden />
|
||||
<span>ca. {durationMinutes} min</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -701,7 +701,7 @@ export function MissionActionCard({
|
||||
{card.title}
|
||||
</p>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-slate-600 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
className={`h-4 w-4 text-slate-600 transition-transform dark:text-white/70 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">Titel ein- oder ausklappen</span>
|
||||
@@ -728,7 +728,7 @@ export function MissionActionCard({
|
||||
<Skeleton className="mx-auto h-4 w-5/6" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">Ziehe deine erste Mission oder wähle eine Stimmung.</p>
|
||||
<p className="text-sm text-slate-600 dark:text-white/70">Ziehe deine erste Mission oder wähle eine Stimmung.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -736,10 +736,10 @@ export function MissionActionCard({
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1 text-left">
|
||||
<p className="text-sm font-semibold text-slate-800" style={titleFont}>
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-white" style={titleFont}>
|
||||
{card.title}
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-slate-600">{card.description}</p>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-white/70">{card.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -770,7 +770,7 @@ export function MissionActionCard({
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full border border-slate-200 bg-white/80 text-slate-800 shadow-sm backdrop-blur"
|
||||
className="w-full border border-slate-200 bg-white/80 text-slate-800 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70 dark:text-white/80"
|
||||
onClick={() => {
|
||||
dismissSwipeHint();
|
||||
onAdvance();
|
||||
@@ -805,7 +805,7 @@ export function MissionActionCard({
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.2, ease: [0.22, 0.61, 0.36, 1] }}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/40 bg-white/85 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-700 shadow-sm backdrop-blur">
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/40 bg-white/85 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70 dark:text-white/80">
|
||||
<motion.span
|
||||
animate={{ x: [-6, 0, -6] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
@@ -983,7 +983,7 @@ export function UploadActionCard({
|
||||
className="hidden"
|
||||
onChange={onPick}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground dark:text-white/70">
|
||||
Kamera öffnen oder ein Foto aus deiner Galerie wählen. Offline möglich – wir laden später hoch.
|
||||
</p>
|
||||
{requiresApproval ? (
|
||||
|
||||
Reference in New Issue
Block a user