Refine admin PWA dark theme controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-04 13:50:59 +01:00
parent 239f55f9c5
commit 66c7131d79
22 changed files with 999 additions and 110 deletions

View File

@@ -3,7 +3,8 @@ import { Loader2, PanelLeftClose, PanelRightOpen } from 'lucide-react';
import { XStack, YStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { useTheme } from '@tamagui/core';
import { useThemeName } from '@tamagui/core';
import { BOTTOM_NAV_HEIGHT, BOTTOM_NAV_PADDING } from './mobile/components/BottomNav';
const DEV_TENANT_KEYS = [
{ key: 'cust-standard-empty', label: 'Endkunde Starter (kein Event)' },
@@ -28,7 +29,9 @@ type DevTenantSwitcherProps = {
export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: DevTenantSwitcherProps) {
const helper = window.fotospielDemoAuth;
const theme = useTheme();
const themeName = useThemeName();
const themeLabel = String(themeName ?? '').toLowerCase();
const isDark = themeLabel.includes('dark') || themeLabel.includes('night');
const [loggingIn, setLoggingIn] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState<boolean>(() => {
if (typeof window === 'undefined') {
@@ -94,12 +97,12 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
return (
<YStack
borderWidth={1}
borderColor="rgba(234,179,8,0.5)"
backgroundColor="rgba(255,255,255,0.95)"
borderColor={isDark ? 'rgba(248,250,255,0.2)' : 'rgba(234,179,8,0.5)'}
backgroundColor={isDark ? 'rgba(15,23,42,0.95)' : 'rgba(255,255,255,0.95)'}
padding="$3"
gap="$2"
borderRadius="$4"
shadowColor="#f59e0b"
shadowColor={isDark ? 'rgba(2,6,23,0.7)' : '#f59e0b'}
shadowOpacity={0.25}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
@@ -107,10 +110,10 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<Text fontSize={13} fontWeight="800" color="#92400e">
<Text fontSize={13} fontWeight="800" color={isDark ? '#F8FAFF' : '#92400e'}>
Demo tenants
</Text>
<Text fontSize={10} color="#a16207" textTransform="uppercase" letterSpacing={1}>
<Text fontSize={10} color={isDark ? 'rgba(248,250,255,0.7)' : '#a16207'} textTransform="uppercase" letterSpacing={1}>
Dev mode
</Text>
</XStack>
@@ -158,7 +161,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
zIndex={1000}
onPress={() => setCollapsed(false)}
aria-label="Demo tenants anzeigen"
style={{ bottom: bottomOffset + 70 }}
style={{ bottom: bottomOffset + BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING }}
>
</Button>
);
@@ -172,23 +175,23 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
maxWidth={320}
gap="$2"
borderWidth={1}
borderColor="rgba(234,179,8,0.5)"
backgroundColor="rgba(255,255,255,0.95)"
borderColor={isDark ? 'rgba(248,250,255,0.2)' : 'rgba(234,179,8,0.5)'}
backgroundColor={isDark ? 'rgba(15,23,42,0.95)' : 'rgba(255,255,255,0.95)'}
padding="$3"
borderRadius="$4"
shadowColor="#f59e0b"
shadowColor={isDark ? 'rgba(2,6,23,0.7)' : '#f59e0b'}
shadowOpacity={0.25}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
pointerEvents="auto"
style={{ bottom: bottomOffset + 70 }}
style={{ bottom: bottomOffset + BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING }}
>
<XStack alignItems="center" justifyContent="space-between">
<XStack alignItems="center" gap="$2">
<Text fontSize={13} fontWeight="800" color="#92400e">
<Text fontSize={13} fontWeight="800" color={isDark ? '#F8FAFF' : '#92400e'}>
Demo tenants
</Text>
<Text fontSize={10} color="#a16207" textTransform="uppercase" letterSpacing={1}>
<Text fontSize={10} color={isDark ? 'rgba(248,250,255,0.7)' : '#a16207'} textTransform="uppercase" letterSpacing={1}>
Dev mode
</Text>
</XStack>
@@ -201,7 +204,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
aria-label="Switcher minimieren"
/>
</XStack>
<Text fontSize={11} color="#a16207">
<Text fontSize={11} color={isDark ? 'rgba(248,250,255,0.7)' : '#a16207'}>
Select a seeded tenant to mint Sanctum PATs and jump straight into their admin space. Available only in development builds.
</Text>
<YStack gap="$1">
@@ -219,7 +222,7 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D
</Button>
))}
</YStack>
<Text fontSize={10} color="#a16207">
<Text fontSize={10} color={isDark ? 'rgba(248,250,255,0.7)' : '#a16207'}>
Console: <Text as="span" fontFamily="$mono">fotospielDemoAuth.loginAs('lumen')</Text>
</Text>
</YStack>

View File

@@ -66,14 +66,15 @@ createRoot(rootEl).render(
function AdminApp() {
const { resolved } = useAppearance();
const themeName = resolved ?? 'light';
const adminThemeName = themeName === 'dark' ? 'adminDark' : 'adminLight';
React.useEffect(() => {
prefetchMobileRoutes();
}, []);
return (
<TamaguiProvider config={tamaguiConfig} defaultTheme={themeName}>
<Theme name={themeName}>
<TamaguiProvider config={tamaguiConfig} defaultTheme={adminThemeName}>
<Theme name={adminThemeName}>
<ConsentProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
@@ -87,8 +88,12 @@ function AdminApp() {
)}
>
<div
className="font-[Manrope] text-[14px] font-normal leading-[1.6]"
style={{ backgroundColor: 'var(--background)', color: 'var(--color)' }}
className="tenant-admin-theme font-[Manrope] text-[14px] font-normal leading-[1.6]"
style={{
backgroundColor: 'var(--background)',
color: 'var(--foreground)',
colorScheme: themeName === 'dark' ? 'dark' : 'light',
}}
>
<RouterProvider router={router} />
</div>

View File

@@ -251,9 +251,19 @@ function PhotoActionButton({
}
function PhotoStatusTag({ label }: { label: string }) {
const { textStrong } = useAdminTheme();
const badgeBg = withAlpha(textStrong, 0.14);
const badgeBorder = withAlpha(textStrong, 0.25);
return (
<XStack paddingHorizontal={6} paddingVertical={2} borderRadius={999} backgroundColor={withAlpha('#0f172a', 0.7)}>
<Text fontSize={9} fontWeight="700" color="#fff">
<XStack
paddingHorizontal={6}
paddingVertical={2}
borderRadius={999}
backgroundColor={badgeBg}
borderWidth={1}
borderColor={badgeBorder}
>
<Text fontSize={9} fontWeight="700" color={textStrong}>
{label}
</Text>
</XStack>
@@ -280,7 +290,9 @@ export default function MobileEventControlRoomPage() {
const isMember = user?.role === 'member';
const slug = slugParam ?? activeEvent?.slug ?? null;
const online = useOnlineStatus();
const { textStrong, text, muted, border, primary, danger, accent, surfaceMuted, surface } = useAdminTheme();
const { textStrong, text, muted, border, primary, danger, accent, accentSoft, surfaceMuted, surface } = useAdminTheme();
const activePillBg = accentSoft ?? withAlpha(primary, 0.18);
const activePillBorder = withAlpha(primary, 0.45);
const [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation');
const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]);
@@ -1394,7 +1406,8 @@ export default function MobileEventControlRoomPage() {
value={option.value}
borderRadius="$4"
borderWidth={1}
borderColor={border}
borderColor={active ? activePillBorder : border}
backgroundColor={active ? activePillBg : 'transparent'}
paddingVertical="$2"
paddingHorizontal="$3"
width="auto"
@@ -1402,7 +1415,7 @@ export default function MobileEventControlRoomPage() {
flexShrink={0}
hoverStyle={{ backgroundColor: '$backgroundHover' }}
pressStyle={{ backgroundColor: '$backgroundPress' }}
activeStyle={{ backgroundColor: '$backgroundPress', borderColor: '$borderColorPress' }}
activeStyle={{ backgroundColor: activePillBg, borderColor: activePillBorder }}
>
<XStack alignItems="center" gap="$2">
<Text fontSize="$xs" fontWeight={active ? '700' : '600'} color={active ? text : muted}>
@@ -1564,21 +1577,22 @@ export default function MobileEventControlRoomPage() {
const count = liveCounts[option.value] ?? 0;
const active = option.value === liveStatusFilter;
return (
<ToggleGroup.Item
key={option.value}
value={option.value}
borderRadius="$4"
borderWidth={1}
borderColor={border}
paddingVertical="$2"
paddingHorizontal="$3"
width="auto"
minWidth="max-content"
flexShrink={0}
hoverStyle={{ backgroundColor: '$backgroundHover' }}
pressStyle={{ backgroundColor: '$backgroundPress' }}
activeStyle={{ backgroundColor: '$backgroundPress', borderColor: '$borderColorPress' }}
>
<ToggleGroup.Item
key={option.value}
value={option.value}
borderRadius="$4"
borderWidth={1}
borderColor={active ? activePillBorder : border}
backgroundColor={active ? activePillBg : 'transparent'}
paddingVertical="$2"
paddingHorizontal="$3"
width="auto"
minWidth="max-content"
flexShrink={0}
hoverStyle={{ backgroundColor: '$backgroundHover' }}
pressStyle={{ backgroundColor: '$backgroundPress' }}
activeStyle={{ backgroundColor: activePillBg, borderColor: activePillBorder }}
>
<XStack alignItems="center" gap="$2">
<Text fontSize="$xs" fontWeight={active ? '700' : '600'} color={active ? text : muted}>
{t(option.labelKey, option.fallback)}

View File

@@ -17,14 +17,16 @@ import toast from 'react-hot-toast';
import { MobileSheet } from './components/Sheet';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import { useAdminTheme, withAlpha } from './theme';
export default function MobileEventMembersPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const { textStrong, text, muted, border, primary, danger } = useAdminTheme();
const { textStrong, text, muted, border, primary, danger, accentSoft } = useAdminTheme();
const activePillBg = accentSoft ?? withAlpha(primary, 0.18);
const activePillBorder = withAlpha(primary, 0.45);
const [members, setMembers] = React.useState<EventMember[]>([]);
const [loading, setLoading] = React.useState(true);
@@ -243,22 +245,28 @@ export default function MobileEventMembersPage() {
borderColor={border}
backgroundColor="$background"
>
{statusOptions.map((option) => (
{statusOptions.map((option) => {
const isActive = statusFilter === option.key;
return (
<ToggleGroup.Item
key={option.key}
value={option.key}
borderRadius="$pill"
borderWidth={1}
borderColor={isActive ? activePillBorder : border}
backgroundColor={isActive ? activePillBg : 'transparent'}
paddingHorizontal="$3"
paddingVertical="$1.5"
width="auto"
minWidth="max-content"
flexShrink={0}
>
<Text fontSize="$xs" fontWeight="600">
<Text fontSize="$xs" fontWeight={isActive ? '700' : '600'} color={isActive ? text : muted}>
{option.label}
</Text>
</ToggleGroup.Item>
))}
);
})}
</ToggleGroup>
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t('events.members.filters.roleLabel', 'Role')}
@@ -278,22 +286,28 @@ export default function MobileEventMembersPage() {
borderColor={border}
backgroundColor="$background"
>
{roleOptions.map((option) => (
{roleOptions.map((option) => {
const isActive = roleFilter === option.key;
return (
<ToggleGroup.Item
key={option.key}
value={option.key}
borderRadius="$pill"
borderWidth={1}
borderColor={isActive ? activePillBorder : border}
backgroundColor={isActive ? activePillBg : 'transparent'}
paddingHorizontal="$3"
paddingVertical="$1.5"
width="auto"
minWidth="max-content"
flexShrink={0}
>
<Text fontSize="$xs" fontWeight="600">
<Text fontSize="$xs" fontWeight={isActive ? '700' : '600'} color={isActive ? text : muted}>
{option.label}
</Text>
</ToggleGroup.Item>
))}
);
})}
</ToggleGroup>
</YStack>
) : null}

View File

@@ -20,7 +20,7 @@ import { getApiErrorMessage } from '../lib/apiError';
import { useBackNavigation } from './hooks/useBackNavigation';
import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters';
import { buildEventListStats } from './lib/eventListStats';
import { useAdminTheme } from './theme';
import { useAdminTheme, withAlpha } from './theme';
import { useAuth } from '../auth/context';
export default function MobileEventsPage() {
@@ -235,7 +235,9 @@ function EventsList({
onEdit?: (slug: string) => void;
}) {
const { t } = useTranslation('management');
const { text, muted, subtle, border, primary, surface, surfaceMuted, shadow } = useAdminTheme();
const { text, muted, subtle, border, primary, surface, surfaceMuted, shadow, accentSoft } = useAdminTheme();
const activePillBg = accentSoft ?? withAlpha(primary, 0.18);
const activePillBorder = withAlpha(primary, 0.45);
const statusCounts = React.useMemo(() => buildEventStatusCounts(events), [events]);
const filteredByStatus = React.useMemo(
@@ -329,7 +331,8 @@ function EventsList({
value={filter.key}
borderRadius="$4"
borderWidth={1}
borderColor={border}
borderColor={active ? activePillBorder : border}
backgroundColor={active ? activePillBg : 'transparent'}
paddingVertical="$2"
paddingHorizontal="$3"
width="auto"
@@ -337,7 +340,7 @@ function EventsList({
flexShrink={0}
hoverStyle={{ backgroundColor: '$backgroundHover' }}
pressStyle={{ backgroundColor: '$backgroundPress' }}
activeStyle={{ backgroundColor: '$backgroundPress', borderColor: '$borderColorPress' }}
activeStyle={{ backgroundColor: activePillBg, borderColor: activePillBorder }}
>
<XStack alignItems="center" gap="$2">
<Text fontSize="$xs" fontWeight={active ? '700' : '600'} color={active ? text : muted}>

View File

@@ -22,7 +22,7 @@ import { useBackNavigation } from './hooks/useBackNavigation';
import { groupNotificationsByScope, type NotificationScope, type NotificationGroup } from './lib/notificationGrouping';
import { collectUnreadIds } from './lib/notificationUnread';
import { formatRelativeTime } from './lib/relativeTime';
import { useAdminTheme } from './theme';
import { useAdminTheme, withAlpha } from './theme';
type NotificationItem = {
id: string;
@@ -337,7 +337,9 @@ export default function MobileNotificationsPage() {
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [showEventPicker, setShowEventPicker] = React.useState(false);
const back = useBackNavigation(adminPath('/mobile/dashboard'));
const { text, muted, border, warningBg, warningText, infoBg, primary, danger, subtle } = useAdminTheme();
const { text, muted, border, warningBg, warningText, infoBg, primary, danger, subtle, accentSoft } = useAdminTheme();
const activePillBg = accentSoft ?? withAlpha(primary, 0.18);
const activePillBorder = withAlpha(primary, 0.45);
const warningIcon = warningText;
const infoIcon = primary;
const errorText = danger;
@@ -587,11 +589,16 @@ export default function MobileNotificationsPage() {
{ key: 'events', label: t('notificationLogs.scope.events', 'Events') },
{ key: 'package', label: t('notificationLogs.scope.package', 'Package') },
{ key: 'general', label: t('notificationLogs.scope.general', 'General') },
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => (
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => {
const isActive = (scopeParam ?? 'all') === filter.key;
return (
<ToggleGroup.Item
key={filter.key}
value={filter.key}
borderRadius="$pill"
borderWidth={1}
borderColor={isActive ? activePillBorder : border}
backgroundColor={isActive ? activePillBg : 'transparent'}
paddingVertical="$2"
paddingHorizontal="$3"
flexGrow={1}
@@ -599,11 +606,12 @@ export default function MobileNotificationsPage() {
width="auto"
minWidth="max-content"
>
<Text fontSize="$xs" fontWeight="600" textAlign="center">
<Text fontSize="$xs" fontWeight={isActive ? '700' : '600'} textAlign="center" color={isActive ? text : muted}>
{filter.label}
</Text>
</ToggleGroup.Item>
))}
);
})}
</ToggleGroup>
{loading ? (

View File

@@ -21,18 +21,17 @@ const ThemeProbe = () => {
);
};
const renderWithTheme = (name: 'light' | 'dark') =>
render(
<TamaguiProvider config={tamaguiConfig} defaultTheme={name}>
<Theme name={name}>
<ThemeProbe />
</Theme>
</TamaguiProvider>
);
const renderTree = (name: 'adminLight' | 'adminDark') => (
<TamaguiProvider config={tamaguiConfig} defaultTheme={name}>
<Theme name={name}>
<ThemeProbe />
</Theme>
</TamaguiProvider>
);
describe('useAdminTheme', () => {
it('tracks Tamagui theme values across light and dark modes', () => {
const { rerender } = renderWithTheme('light');
const { rerender } = render(renderTree('adminLight'));
const probe = screen.getByTestId('probe');
const lightAdminBg = probe.getAttribute('data-admin-bg');
const lightThemeBg = probe.getAttribute('data-theme-bg');
@@ -42,13 +41,7 @@ describe('useAdminTheme', () => {
expect(lightAdminBg).toBe(lightThemeBg);
expect(lightAdminText).toBe(lightThemeText);
rerender(
<TamaguiProvider config={tamaguiConfig} defaultTheme="dark">
<Theme name="dark">
<ThemeProbe />
</Theme>
</TamaguiProvider>
);
rerender(renderTree('adminDark'));
const darkProbe = screen.getByTestId('probe');
const darkAdminBg = darkProbe.getAttribute('data-admin-bg');

View File

@@ -215,6 +215,7 @@ vi.mock('../theme', () => ({
accent: '#6366f1',
shadow: 'rgba(15,23,42,0.12)',
}),
withAlpha: (value: string) => value,
}));
import MobileEventsPage from '../EventsPage';

View File

@@ -128,7 +128,9 @@ vi.mock('../theme', () => ({
successText: '#166534',
infoBg: '#e0e7ff',
infoText: '#3730a3',
accentSoft: '#eef2ff',
}),
withAlpha: (value: string) => value,
}));
import MobileNotificationsPage from '../NotificationsPage';

View File

@@ -10,6 +10,7 @@ import { adminPath } from '../../constants';
const ICON_SIZE = 24;
export const BOTTOM_NAV_HEIGHT = 70;
export const BOTTOM_NAV_PADDING = 8;
export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
@@ -51,7 +52,7 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
shadowRadius={20}
shadowOffset={{ width: 0, height: -8 }}
style={{
paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)',
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_PADDING}px)`,
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}

View File

@@ -95,7 +95,7 @@ export const MobileDateTimeInput = React.forwardRef<
HTMLInputElement,
React.ComponentPropsWithoutRef<'input'> & ControlProps
>(function MobileDateTimeInput({ hasError = false, style, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const { border, surface, text, primary, danger, isDark } = useAdminTheme();
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const borderColor = hasError ? danger : border;
@@ -114,6 +114,9 @@ export const MobileDateTimeInput = React.forwardRef<
borderColor,
backgroundColor: surface,
color: text,
WebkitAppearance: 'none',
appearance: 'none',
colorScheme: isDark ? 'dark' : 'light',
fontSize: 14,
outline: 'none',
boxShadow: `0 0 0 0 ${ringColor}`,
@@ -135,7 +138,7 @@ export const MobileDateInput = React.forwardRef<
HTMLInputElement,
React.ComponentPropsWithoutRef<'input'> & ControlProps
>(function MobileDateInput({ hasError = false, style, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const { border, surface, text, primary, danger, isDark } = useAdminTheme();
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const borderColor = hasError ? danger : border;
@@ -154,6 +157,9 @@ export const MobileDateInput = React.forwardRef<
borderColor,
backgroundColor: surface,
color: text,
WebkitAppearance: 'none',
appearance: 'none',
colorScheme: isDark ? 'dark' : 'light',
fontSize: 14,
outline: 'none',
boxShadow: `0 0 0 0 ${ringColor}`,
@@ -173,7 +179,7 @@ export const MobileDateInput = React.forwardRef<
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const { border, surface, text, primary, danger, isDark } = useAdminTheme();
const borderColor = hasError ? danger : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const isPassword = type === 'password';
@@ -196,6 +202,12 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
backgroundColor={surface}
color={text}
borderColor={borderColor}
style={{
WebkitAppearance: 'none',
appearance: 'none',
colorScheme: isDark ? 'dark' : 'light',
...style,
} as any}
focusStyle={{
borderColor: hasError ? danger : primary,
boxShadow: `0 0 0 3px ${ringColor}`,
@@ -203,7 +215,6 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
hoverStyle={{
borderColor,
} as any}
style={style as any}
/>
);
},
@@ -213,7 +224,7 @@ export const MobileTextArea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentPropsWithoutRef<'textarea'> & ControlProps
>(function MobileTextArea({ hasError = false, compact = false, style, onChange, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const { border, surface, text, primary, danger, isDark } = useAdminTheme();
const borderColor = hasError ? danger : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
@@ -233,6 +244,13 @@ export const MobileTextArea = React.forwardRef<
backgroundColor={surface}
color={text}
borderColor={borderColor}
style={{
resize: 'vertical',
WebkitAppearance: 'none',
appearance: 'none',
colorScheme: isDark ? 'dark' : 'light',
...style,
} as any}
focusStyle={{
borderColor: hasError ? danger : primary,
boxShadow: `0 0 0 3px ${ringColor}`,
@@ -240,7 +258,6 @@ export const MobileTextArea = React.forwardRef<
hoverStyle={{
borderColor,
} as any}
style={{ resize: 'vertical', ...style } as any}
/>
);
});
@@ -253,7 +270,8 @@ export function MobileSelect({
style,
...props
}: MobileSelectProps) {
const { border, surface, text, primary, danger, subtle, glassSurfaceStrong, surfaceMuted, glassBorder } = useAdminTheme();
const { border, surface, text, primary, danger, subtle, glassSurfaceStrong, surfaceMuted, glassBorder, isDark } =
useAdminTheme();
const borderColor = hasError ? danger : (glassBorder ?? border);
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const triggerSurface = surfaceMuted ?? glassSurfaceStrong ?? surface;
@@ -316,7 +334,12 @@ export function MobileSelect({
hoverStyle={{
borderColor: borderColor as any,
}}
style={style as any}
style={{
WebkitAppearance: 'none',
appearance: 'none',
colorScheme: isDark ? 'dark' : 'light',
...style,
} as any}
>
<Select.Value placeholder={props.placeholder ?? (emptyOption?.label as any) ?? ''} {...({ color: text } as any)} />
</Select.Trigger>

View File

@@ -5,7 +5,7 @@ import { YStack, XStack, SizableText as Text, Image } from 'tamagui';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTranslation } from 'react-i18next';
import { useEventContext } from '../../context/EventContext';
import { BottomNav, BOTTOM_NAV_HEIGHT, NavKey } from './BottomNav';
import { BottomNav, BOTTOM_NAV_HEIGHT, BOTTOM_NAV_PADDING, NavKey } from './BottomNav';
import { useMobileNav } from '../hooks/useMobileNav';
import { ADMIN_EVENTS_PATH, adminPath } from '../../constants';
import { MobileCard, CTAButton } from './Primitives';
@@ -336,7 +336,9 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
gap="$3"
width="100%"
maxWidth={800}
style={{ paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_HEIGHT}px)` }}
style={{
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING}px)`,
}}
>
{!online ? (
<XStack alignItems="center" justifyContent="center" borderRadius={12} backgroundColor={theme.warningBg} paddingVertical="$2" paddingHorizontal="$3">

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Card, YStack, XStack, SizableText as Text, Tabs, Separator } from 'tamagui';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useAdminTheme } from '../theme';
import { BOTTOM_NAV_HEIGHT, BOTTOM_NAV_PADDING } from './BottomNav';
import { withAlpha } from './colors';
export function MobileCard({
@@ -393,7 +394,7 @@ export function FloatingActionButton({
style={{
position: 'fixed',
right: 18,
bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)',
bottom: `calc(env(safe-area-inset-bottom, 0px) + ${BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING + 12}px)`,
zIndex: 60,
transform: pressed ? 'scale(0.96)' : 'scale(1)',
opacity: pressed ? 0.92 : 1,
@@ -434,7 +435,10 @@ export function ContentTabs({
tabs: { value: string; label: string; content: React.ReactNode }[];
header?: React.ReactNode;
}) {
const { muted, text } = useAdminTheme();
const { muted, text, border, glassBorder, accentSoft, primary } = useAdminTheme();
const tabBorder = glassBorder ?? border;
const activeBg = accentSoft ?? withAlpha(primary, 0.18);
const activeBorder = withAlpha(primary, 0.45);
return (
<Tabs
defaultValue={value}
@@ -444,11 +448,13 @@ export function ContentTabs({
flexDirection="column"
borderRadius="$4"
borderWidth={1}
borderColor="$borderColor"
borderColor={tabBorder}
overflow="hidden"
>
<Tabs.List separator={<Separator vertical />} disablePassBorderRadius="bottom">
{tabs.map((tab) => (
{tabs.map((tab) => {
const isActive = value === tab.value;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
@@ -457,19 +463,23 @@ export function ContentTabs({
paddingHorizontal="$3"
alignItems="center"
justifyContent="center"
borderWidth={1}
borderColor={isActive ? activeBorder : 'transparent'}
backgroundColor={isActive ? activeBg : 'transparent'}
hoverStyle={{ backgroundColor: '$backgroundHover' }}
pressStyle={{ backgroundColor: '$backgroundPress' }}
activeStyle={{ backgroundColor: '$backgroundPress' }}
activeStyle={{ backgroundColor: activeBg, borderColor: activeBorder }}
>
<Text
fontSize="$sm"
fontWeight={value === tab.value ? '700' : '600'}
color={value === tab.value ? text : muted}
fontWeight={isActive ? '700' : '600'}
color={isActive ? text : muted}
>
{tab.label}
</Text>
</Tabs.Tab>
))}
);
})}
</Tabs.List>
{header}

View File

@@ -5,6 +5,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { Sheet } from '@tamagui/sheet';
import { useTranslation } from 'react-i18next';
import { useAdminTheme } from '../theme';
import { BOTTOM_NAV_HEIGHT, BOTTOM_NAV_PADDING } from './BottomNav';
type SheetProps = {
open: boolean;
@@ -30,7 +31,7 @@ export function MobileSheet({
contentSpacing = '$3',
padding = '$4',
paddingBottom = '$7',
bottomOffsetPx = 88,
bottomOffsetPx = BOTTOM_NAV_HEIGHT + BOTTOM_NAV_PADDING,
}: SheetProps) {
const { t } = useTranslation('mobile');
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();

View File

@@ -1,12 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight, CreditCard, FileText, HelpCircle, User, X } from 'lucide-react';
import { XStack, YStack, SizableText as Text, ListItem, YGroup, Switch, Separator } from 'tamagui';
import { ChevronRight, CreditCard, FileText, HelpCircle, Moon, Sun, User, X } from 'lucide-react';
import { XStack, YStack, SizableText as Text, ListItem, YGroup, Separator } from 'tamagui';
import { Pressable } from '@tamagui/react-native-web-lite';
import { ToggleGroup } from '@tamagui/toggle-group';
import { useAppearance } from '@/hooks/use-appearance';
import { ADMIN_BILLING_PATH, ADMIN_DATA_EXPORTS_PATH, ADMIN_FAQ_PATH, ADMIN_PROFILE_ACCOUNT_PATH, adminPath } from '../../constants';
import { useAdminTheme } from '../theme';
import { useAdminTheme, withAlpha } from '../theme';
import { MobileSelect } from './FormControls';
type UserMenuSheetProps = {
@@ -30,7 +31,9 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
setLanguage(i18n.language?.startsWith('en') ? 'en' : 'de');
}, [i18n.language]);
const isDark = resolved === 'dark';
const themeValue: 'light' | 'dark' = (appearance === 'system' ? resolved : appearance) ?? 'light';
const activeToggleBg = theme.accentSoft ?? withAlpha(theme.primary, 0.18);
const activeToggleBorder = withAlpha(theme.primary, 0.45);
const handleNavigate = (path: string) => {
onClose();
@@ -239,14 +242,46 @@ export function UserMenuSheet({ open, onClose, user, isMember, navigate }: UserM
</XStack>
}
iconAfter={
<Switch
size="$2"
checked={isDark}
onCheckedChange={(next: boolean) => updateAppearance(next ? 'dark' : 'light')}
aria-label={t('mobileProfile.theme', 'Dark Mode')}
<ToggleGroup
type="single"
value={themeValue}
onValueChange={(next: string) => {
if (next === 'light' || next === 'dark') {
updateAppearance(next);
}
}}
disableDeactivation
orientation="horizontal"
flexDirection="row"
gap="$1.5"
>
<Switch.Thumb />
</Switch>
{([
{ key: 'light', label: t('mobileProfile.themeLight', 'Light'), icon: Sun },
{ key: 'dark', label: t('mobileProfile.themeDark', 'Dark'), icon: Moon },
] as const).map((option) => {
const active = option.key === themeValue;
const Icon = option.icon;
return (
<ToggleGroup.Item
key={option.key}
value={option.key}
borderRadius="$pill"
borderWidth={1}
borderColor={active ? activeToggleBorder : theme.border}
backgroundColor={active ? activeToggleBg : 'transparent'}
paddingHorizontal="$2.5"
paddingVertical="$1.5"
>
<XStack alignItems="center" gap="$1.5">
<Icon size={14} color={active ? theme.textStrong : theme.muted} />
<Text fontSize="$xs" fontWeight={active ? '700' : '600'} color={active ? theme.textStrong : theme.muted}>
{option.label}
</Text>
</XStack>
</ToggleGroup.Item>
);
})}
</ToggleGroup>
}
/>
</YGroup.Item>

View File

@@ -55,6 +55,7 @@ vi.mock('tamagui', () => ({
vi.mock('../BottomNav', () => ({
BottomNav: () => <div data-testid="bottom-nav" />,
BOTTOM_NAV_HEIGHT: 70,
BOTTOM_NAV_PADDING: 8,
NavKey: {},
}));

View File

@@ -17,8 +17,6 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
vi.mock('tamagui', () => {
const Stack = ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>;
const Text = ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>;
const Switch = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Switch.Thumb = () => <div />;
const ListItem = ({ title, iconAfter, ...props }: { title?: React.ReactNode; iconAfter?: React.ReactNode }) => (
<div {...props}>
@@ -36,11 +34,16 @@ vi.mock('tamagui', () => {
SizableText: Text,
ListItem,
YGroup,
Switch,
Separator: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
};
});
vi.mock('@tamagui/toggle-group', () => ({
ToggleGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('../FormControls', () => ({
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
}));
@@ -66,6 +69,7 @@ vi.mock('../../theme', () => ({
glassShadow: 'rgba(15,23,42,0.14)',
shadow: 'rgba(0,0,0,0.12)',
}),
withAlpha: (value: string) => value,
}));
import { UserMenuSheet } from '../UserMenuSheet';

View File

@@ -110,8 +110,8 @@ export function useAdminTheme() {
// Muted/Subtle should NOT use theme.muted (which is a background color in Tamagui standard)
// Instead, we derive them from Text with opacity or use specific palette values if available
// But safer is Alpha since it works in both Light (Dark Text) and Dark (Light Text) modes.
const muted = withAlpha(text, 0.65);
const subtle = withAlpha(text, 0.45);
const muted = resolveThemeValue((theme as any).colorMuted?.val, withAlpha(text, 0.65));
const subtle = resolveThemeValue((theme as any).colorSubtle?.val, withAlpha(text, 0.45));
const surfaceMuted = resolveThemeValue(theme.muted?.val, isDark ? '#121F3D' : ADMIN_COLORS.surfaceMuted);
const glassSurface = withAlpha(surface, isDark ? 0.90 : 0.85);
@@ -122,6 +122,7 @@ export function useAdminTheme() {
return {
theme,
isDark,
background,
surface,
surfaceMuted,
@@ -134,7 +135,7 @@ export function useAdminTheme() {
subtle, // Now properly derived from text color
primary,
accent: resolveThemeValue(theme.accent?.val, ADMIN_COLORS.accent),
accentSoft: resolveThemeValue(theme.blue3?.val, ADMIN_COLORS.accentSoft),
accentSoft: resolveThemeValue((theme as any).accentSoft?.val ?? theme.blue3?.val, ADMIN_COLORS.accentSoft),
accentStrong: resolveThemeValue(theme.blue11?.val, ADMIN_COLORS.primaryStrong),
successBg: resolveThemeValue(theme.backgroundStrong?.val, '#DCFCE7'),
successText: resolveThemeValue(theme.green10?.val, ADMIN_COLORS.success),