From 2a872d2ccf4739cd716f21069acfb8c734e06a7a Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Wed, 29 Jul 2026 09:40:40 +0200 Subject: [PATCH] Add selectable color themes with a global appearance switcher --- src/components/common/ThemeSwitcher.tsx | 78 +++++++++++++++ src/components/layout/TopBar.tsx | 18 +--- src/i18n/en.json | 18 +++- src/index.css | 124 ++++++++++++++++++++++++ src/main.tsx | 19 ++-- src/stores/uiStore.ts | 46 ++++++--- 6 files changed, 263 insertions(+), 40 deletions(-) create mode 100644 src/components/common/ThemeSwitcher.tsx diff --git a/src/components/common/ThemeSwitcher.tsx b/src/components/common/ThemeSwitcher.tsx new file mode 100644 index 0000000..02f3993 --- /dev/null +++ b/src/components/common/ThemeSwitcher.tsx @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { useTranslation } from 'react-i18next'; +import { Moon, Palette, Sun, Square, Radius } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { COLOR_THEMES, useUIStore, type ColorTheme, type Radius as RadiusValue, type Theme } from '@/stores/uiStore'; + +export function ThemeSwitcher() { + const { t } = useTranslation(); + const theme = useUIStore((s) => s.theme); + const setTheme = useUIStore((s) => s.setTheme); + const colorTheme = useUIStore((s) => s.colorTheme); + const setColorTheme = useUIStore((s) => s.setColorTheme); + const radius = useUIStore((s) => s.radius); + const setRadius = useUIStore((s) => s.setRadius); + + return ( + + + + + + {t('appearance.mode', 'Mode')} + setTheme(value as Theme)}> + + + {t('appearance.light', 'Light')} + + + + {t('appearance.dark', 'Dark')} + + + + {t('appearance.colorTheme', 'Color theme')} + setColorTheme(value as ColorTheme)}> + {COLOR_THEMES.map((entry) => ( + + + {t(entry.labelKey, entry.fallback)} + + ))} + + + {t('appearance.corners', 'Corners')} + setRadius(value as RadiusValue)}> + + + {t('appearance.rounded', 'Rounded')} + + + + {t('appearance.square', 'Square')} + + + + + ); +} diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index e2ee27f..52cd6f1 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -7,7 +7,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import * as LucideIcons from 'lucide-react'; -const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search, Radius } = LucideIcons; +const { User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons; import { Button } from '@/components/ui/button'; import { CommandPalette } from '@/components/common/CommandPalette'; import { @@ -20,6 +20,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import Logo from '@/components/common/Logo'; +import { ThemeSwitcher } from '@/components/common/ThemeSwitcher'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout'; @@ -41,10 +42,6 @@ function getIcon(name: string): LucideIcons.LucideIcon { export function TopBar() { const { t } = useTranslation(); const navigate = useNavigate(); - const theme = useUIStore((s) => s.theme); - const toggleTheme = useUIStore((s) => s.toggleTheme); - const radius = useUIStore((s) => s.radius); - const toggleRadius = useUIStore((s) => s.toggleRadius); const toggleSidebar = useUIStore((s) => s.toggleSidebar); const setActiveSection = useUIStore((s) => s.setActiveSection); const accounts = useAuthStore((s) => s.accounts); @@ -125,9 +122,7 @@ export function TopBar() { - + @@ -191,13 +186,6 @@ export function TopBar() { )} - - - {t('squareCorners', 'Square corners')} - {radius === 'square' && } - - - { diff --git a/src/i18n/en.json b/src/i18n/en.json index efe6b32..a29e736 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1,5 +1,21 @@ { "accounts": "Accounts", + "appearance": { + "colorTheme": "Color theme", + "corners": "Corners", + "dark": "Dark", + "label": "Appearance", + "light": "Light", + "mode": "Mode", + "rounded": "Rounded", + "square": "Square", + "theme": { + "forest": "Forest", + "ocean": "Ocean", + "stalwart": "Stalwart", + "violet": "Violet" + } + }, "actions": { "actionFailed": "Action failed.", "backToActions": "Back to actions", @@ -337,8 +353,6 @@ "periodValue": "Period value" }, "sections": "Sections", - "squareCorners": "Square corners", - "toggleTheme": "Toggle theme", "tracing": { "addFilter": "Add filter", "bufferFull": "(buffer full)", diff --git a/src/index.css b/src/index.css index 208ecdb..527d8f5 100644 --- a/src/index.css +++ b/src/index.css @@ -96,6 +96,130 @@ --radius: 0px; } +/* Color themes. Each theme re-hues the neutral tokens at the same lightness + and chroma as the default palette (contrast is preserved) and replaces + primary/ring with the theme color. The default "Stalwart" theme is the + bare :root/.dark above and needs no data-theme attribute. Dark theme + blocks must stay after their light counterpart: both selectors have the + same specificity, so source order decides. */ + +:root[data-theme='ocean'] { + --foreground: oklch(0.145 0.02 264); + --content-background: oklch(0.965 0.008 264); + --primary: oklch(0.546 0.215 262.9); + --primary-foreground: oklch(0.985 0.002 264); + --secondary: oklch(0.955 0.012 264); + --secondary-foreground: oklch(0.3 0.035 264); + --muted: oklch(0.935 0.01 264); + --muted-foreground: oklch(0.556 0.022 264); + --accent: oklch(0.94 0.018 264); + --accent-foreground: oklch(0.3 0.035 264); + --border: oklch(0.912 0.012 264); + --input: oklch(0.912 0.012 264); + --ring: oklch(0.546 0.215 262.9); + --chart-1: 221 83% 53%; +} + +.dark[data-theme='ocean'] { + --background: oklch(0.152 0.014 264); + --foreground: oklch(0.96 0.008 264); + --card: oklch(0.235 0.02 264); + --card-foreground: oklch(0.96 0.008 264); + --popover: oklch(0.208 0.018 264); + --popover-foreground: oklch(0.96 0.008 264); + --content-background: oklch(0.122 0.012 264); + --primary: oklch(0.623 0.214 259.8); + --primary-foreground: oklch(0.985 0.002 264); + --secondary: oklch(0.28 0.022 264); + --secondary-foreground: oklch(0.96 0.008 264); + --muted: oklch(0.28 0.022 264); + --muted-foreground: oklch(0.715 0.022 264); + --accent: oklch(0.305 0.028 264); + --accent-foreground: oklch(0.96 0.008 264); + --border: oklch(0.29 0.024 264); + --input: oklch(0.29 0.024 264); + --ring: oklch(0.623 0.214 259.8); + --chart-1: 217 91% 60%; +} + +:root[data-theme='forest'] { + --foreground: oklch(0.145 0.02 150); + --content-background: oklch(0.965 0.008 150); + --primary: oklch(0.527 0.137 150.1); + --primary-foreground: oklch(0.985 0.004 150); + --secondary: oklch(0.955 0.012 150); + --secondary-foreground: oklch(0.3 0.035 150); + --muted: oklch(0.935 0.01 150); + --muted-foreground: oklch(0.556 0.022 150); + --accent: oklch(0.94 0.018 150); + --accent-foreground: oklch(0.3 0.035 150); + --border: oklch(0.912 0.012 150); + --input: oklch(0.912 0.012 150); + --ring: oklch(0.527 0.137 150.1); + --chart-1: 142 71% 45%; +} + +.dark[data-theme='forest'] { + --background: oklch(0.152 0.012 150); + --foreground: oklch(0.96 0.008 150); + --card: oklch(0.235 0.018 150); + --card-foreground: oklch(0.96 0.008 150); + --popover: oklch(0.208 0.016 150); + --popover-foreground: oklch(0.96 0.008 150); + --content-background: oklch(0.122 0.01 150); + --primary: oklch(0.723 0.219 149.6); + --primary-foreground: oklch(0.2 0.03 150); + --secondary: oklch(0.28 0.02 150); + --secondary-foreground: oklch(0.96 0.008 150); + --muted: oklch(0.28 0.02 150); + --muted-foreground: oklch(0.715 0.02 150); + --accent: oklch(0.305 0.024 150); + --accent-foreground: oklch(0.96 0.008 150); + --border: oklch(0.29 0.022 150); + --input: oklch(0.29 0.022 150); + --ring: oklch(0.723 0.219 149.6); + --chart-1: 142 69% 58%; +} + +:root[data-theme='violet'] { + --foreground: oklch(0.145 0.02 290); + --content-background: oklch(0.965 0.008 290); + --primary: oklch(0.541 0.281 293); + --primary-foreground: oklch(0.985 0.002 290); + --secondary: oklch(0.955 0.012 290); + --secondary-foreground: oklch(0.3 0.04 290); + --muted: oklch(0.935 0.01 290); + --muted-foreground: oklch(0.556 0.025 290); + --accent: oklch(0.94 0.02 290); + --accent-foreground: oklch(0.3 0.04 290); + --border: oklch(0.912 0.014 290); + --input: oklch(0.912 0.014 290); + --ring: oklch(0.541 0.281 293); + --chart-1: 262 83% 58%; +} + +.dark[data-theme='violet'] { + --background: oklch(0.152 0.014 290); + --foreground: oklch(0.96 0.008 290); + --card: oklch(0.235 0.02 290); + --card-foreground: oklch(0.96 0.008 290); + --popover: oklch(0.208 0.018 290); + --popover-foreground: oklch(0.96 0.008 290); + --content-background: oklch(0.122 0.012 290); + --primary: oklch(0.702 0.183 293.5); + --primary-foreground: oklch(0.21 0.03 293); + --secondary: oklch(0.28 0.024 290); + --secondary-foreground: oklch(0.96 0.008 290); + --muted: oklch(0.28 0.024 290); + --muted-foreground: oklch(0.715 0.024 290); + --accent: oklch(0.305 0.03 290); + --accent-foreground: oklch(0.96 0.008 290); + --border: oklch(0.29 0.026 290); + --input: oklch(0.29 0.026 290); + --ring: oklch(0.702 0.183 293.5); + --chart-1: 263 70% 66%; +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); diff --git a/src/main.tsx b/src/main.tsx index 06656fa..6c9200b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,18 +20,19 @@ import { getBasePath } from './lib/basePath'; (() => { try { const persisted = localStorage.getItem('stalwart-ui'); - if (persisted) { - const parsed = JSON.parse(persisted); - const theme = parsed?.state?.theme; - if (theme === 'dark' || theme === 'light') { - document.documentElement.classList.toggle('dark', theme === 'dark'); - return; - } + const state = persisted ? (JSON.parse(persisted)?.state ?? null) : null; + const theme = state?.theme; + const dark = theme === 'dark' || (theme !== 'light' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches); + document.documentElement.classList.toggle('dark', dark); + const colorTheme = state?.colorTheme; + if (colorTheme === 'ocean' || colorTheme === 'forest' || colorTheme === 'violet') { + document.documentElement.dataset.theme = colorTheme; + } + if (state?.radius === 'square') { + document.documentElement.dataset.radius = 'square'; } // eslint-disable-next-line no-empty } catch {} - const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches; - document.documentElement.classList.toggle('dark', !!prefersDark); })(); const basePath = getBasePath(); diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index ee0d44b..fd3d237 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -7,18 +7,19 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -type Theme = 'light' | 'dark'; -type Radius = 'rounded' | 'square'; +export type Theme = 'light' | 'dark'; +export type ColorTheme = 'stalwart' | 'ocean' | 'forest' | 'violet'; +export type Radius = 'rounded' | 'square'; interface UIState { theme: Theme; + colorTheme: ColorTheme; radius: Radius; sidebarOpen: boolean; activeSection: string; - toggleTheme: () => void; setTheme: (theme: Theme) => void; - toggleRadius: () => void; + setColorTheme: (colorTheme: ColorTheme) => void; setRadius: (radius: Radius) => void; toggleSidebar: () => void; setSidebarOpen: (open: boolean) => void; @@ -33,34 +34,49 @@ function applyThemeClass(theme: Theme) { } } +function applyColorThemeAttribute(colorTheme: ColorTheme) { + // The default theme is defined directly on :root/.dark, so it needs no attribute. + if (colorTheme === 'stalwart') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.dataset.theme = colorTheme; + } +} + function applyRadiusAttribute(radius: Radius) { document.documentElement.dataset.radius = radius; } +export const COLOR_THEMES: { value: ColorTheme; labelKey: string; fallback: string; swatch: string }[] = [ + { + value: 'stalwart', + labelKey: 'appearance.theme.stalwart', + fallback: 'Stalwart', + swatch: 'linear-gradient(135deg, oklch(0.205 0.017 285.823) 50%, oklch(0.92 0.007 285.823) 50%)', + }, + { value: 'ocean', labelKey: 'appearance.theme.ocean', fallback: 'Ocean', swatch: 'oklch(0.546 0.215 262.9)' }, + { value: 'forest', labelKey: 'appearance.theme.forest', fallback: 'Forest', swatch: 'oklch(0.527 0.137 150.1)' }, + { value: 'violet', labelKey: 'appearance.theme.violet', fallback: 'Violet', swatch: 'oklch(0.541 0.281 293)' }, +]; + export const useUIStore = create()( persist( (set, get) => ({ theme: typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', + colorTheme: 'stalwart', radius: 'rounded', sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true, activeSection: '', - toggleTheme: () => { - const next = get().theme === 'light' ? 'dark' : 'light'; - applyThemeClass(next); - set({ theme: next }); - }, - setTheme: (theme) => { applyThemeClass(theme); set({ theme }); }, - toggleRadius: () => { - const next = get().radius === 'rounded' ? 'square' : 'rounded'; - applyRadiusAttribute(next); - set({ radius: next }); + setColorTheme: (colorTheme) => { + applyColorThemeAttribute(colorTheme); + set({ colorTheme }); }, setRadius: (radius) => { @@ -84,12 +100,14 @@ export const useUIStore = create()( name: 'stalwart-ui', partialize: (state) => ({ theme: state.theme, + colorTheme: state.colorTheme, radius: state.radius, }), onRehydrateStorage: () => { return (state) => { if (state) { applyThemeClass(state.theme); + applyColorThemeAttribute(state.colorTheme ?? 'stalwart'); applyRadiusAttribute(state.radius ?? 'rounded'); } };