From b799ad180b559a2b146d5c6aadeb6f054df7289d Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Wed, 29 Jul 2026 08:43:42 +0200 Subject: [PATCH] =?UTF-8?q?#182:=20top-bar=20theme=20toggle=20=E2=80=94=20?= =?UTF-8?q?cycle=20light/dark/system=20without=20a=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An IconButton between the notifications bell and the user menu cycles the theme mode in radio order (sun/moon/monitor mirror the CURRENT choice). New useThemeMode() hook is the single write path (persist + apply + same-document event), so the settings radios and the toggle stay in sync; AppearanceSection now uses it too. Also rendered for signed-out visitors — the mode is a device-local preference. i18n de+en; unit tests for cycle/setter, theme.spec covers cycling, radio sync, persistence, and the signed-out top bar. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX --- apps/web/e2e/theme.spec.ts | 50 +++++++++++++++++++++++++++ apps/web/src/layout/TopBar.tsx | 31 ++++++++++++++++- apps/web/src/pages/SettingsPage.tsx | 12 +++---- apps/web/src/theme/theme.test.ts | 31 ++++++++++++++++- apps/web/src/theme/theme.ts | 34 ++++++++++++++++++ packages/shared/i18n/de/settings.json | 3 +- packages/shared/i18n/en/settings.json | 3 +- 7 files changed, 153 insertions(+), 11 deletions(-) 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 ? (