diff --git a/apps/web/e2e/theme.spec.ts b/apps/web/e2e/theme.spec.ts index 0dc4e9e..44beb5b 100644 --- a/apps/web/e2e/theme.spec.ts +++ b/apps/web/e2e/theme.spec.ts @@ -64,3 +64,53 @@ test('theme choice applies instantly, persists, and system mode follows the OS', await context.close(); }); + +test('the top-bar toggle cycles the mode and stays in sync with the radios', async ({ + browser, +}) => { + const context = await contextForUser(browser, BASE, 'fixture-user'); + const page = await context.newPage(); + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto('/settings'); + await page.locator('.settings-fieldset').waitFor(); + const toggle = page.locator('.topbar__theme'); + + // Default System → ein Klick zykelt in Radio-Reihenfolge weiter zu Hell, + // dann Dunkel, dann zurück zu System; die Radios folgen (issue #182). + await toggle.click(); + await expect(radio(page, 'light')).toBeChecked(); + expect(await effectiveTheme(page)).toBe('light'); + + await toggle.click(); + await expect(radio(page, 'dark')).toBeChecked(); + expect(await effectiveTheme(page)).toBe('dark'); + + await toggle.click(); + await expect(radio(page, 'system')).toBeChecked(); + expect(await effectiveTheme(page)).toBe('light'); // helles OS + + // Auch andersherum: eine Radio-Wahl versetzt den Zyklus des Toggles. + await radio(page, 'dark').check(); + await toggle.click(); + await expect(radio(page, 'system')).toBeChecked(); + + // Persistenz wie bei den Radios (gleicher localStorage-Key). + await toggle.click(); // → light + await page.reload(); + await page.locator('.settings-fieldset').waitFor(); + await expect(radio(page, 'light')).toBeChecked(); + expect(await effectiveTheme(page)).toBe('light'); + + await context.close(); +}); + +test('the theme toggle works for signed-out visitors', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto(`${BASE}/login`); + const toggle = page.locator('.topbar__theme'); + await toggle.waitFor(); + + await toggle.click(); // System → Hell + await toggle.click(); // Hell → Dunkel + expect(await effectiveTheme(page)).toBe('dark'); +}); diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index de45722..991bc6d 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -1,21 +1,49 @@ import type { PondView } from '@dorfteich/shared'; import { useQuery } from '@tanstack/react-query'; -import { Menu, Search, Settings } from 'lucide-react'; +import { Menu, Monitor, Moon, Search, Settings, Sun } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; +import { IconButton } from '../components/IconButton'; import { apiGet } from '../lib/api'; import { isTypingTarget } from '../lib/keyboard'; import { useDismissable } from '../lib/use-dismissable'; import { SearchPalette } from '../search/SearchPalette'; import { NotificationsBell } from '../notifications/NotificationsBell'; +import { nextThemeMode, useThemeMode, type ThemeMode } from '../theme/theme'; import { usePageActionsSlot } from './page-actions'; import { PondSwitcher } from './PondSwitcher'; import { useCurrentPondRoute } from './use-pond-route'; import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts'; + +/** Icon per CHOSEN mode (not per effective theme): the monitor tells the + * user "following the OS" apart from an explicit light/dark pick. */ +const THEME_MODE_ICONS: Record = { + light: Sun, + dark: Moon, + system: Monitor, +}; + +/** Three-way theme cycle (issue #182) — sits between the bell and the user + * menu; also rendered for signed-out visitors (device-local preference). */ +function ThemeToggle(): React.JSX.Element { + const { t } = useTranslation(); + const [mode, setMode] = useThemeMode(); + const Icon = THEME_MODE_ICONS[mode]; + return ( + setMode(nextThemeMode(mode))} + > + + + ); +} + interface TopBarProps { sidebarCollapsed: boolean; onToggleSidebar: () => void; @@ -108,6 +136,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac )} {searchOpen && user && setSearchOpen(false)} />} {user && } + {user ? (