Add selectable color themes with a global appearance switcher

This commit is contained in:
Steven RYDELL
2026-07-29 09:40:40 +02:00
parent 0a8dfb44be
commit 2a872d2ccf
6 changed files with 263 additions and 40 deletions
+78
View File
@@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('appearance.label', 'Appearance')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>{t('appearance.mode', 'Mode')}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={theme} onValueChange={(value) => setTheme(value as Theme)}>
<DropdownMenuRadioItem value="light">
<Sun className="mr-2 h-4 w-4" />
{t('appearance.light', 'Light')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="mr-2 h-4 w-4" />
{t('appearance.dark', 'Dark')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('appearance.colorTheme', 'Color theme')}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={colorTheme} onValueChange={(value) => setColorTheme(value as ColorTheme)}>
{COLOR_THEMES.map((entry) => (
<DropdownMenuRadioItem key={entry.value} value={entry.value}>
<span
className="mr-2 h-3.5 w-3.5 shrink-0 rounded-full border border-border"
style={{ background: entry.swatch }}
/>
{t(entry.labelKey, entry.fallback)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('appearance.corners', 'Corners')}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={radius} onValueChange={(value) => setRadius(value as RadiusValue)}>
<DropdownMenuRadioItem value="rounded">
<Radius className="mr-2 h-4 w-4" />
{t('appearance.rounded', 'Rounded')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="square">
<Square className="mr-2 h-4 w-4" />
{t('appearance.square', 'Square')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+3 -15
View File
@@ -7,7 +7,7 @@
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react'; 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 { Button } from '@/components/ui/button';
import { CommandPalette } from '@/components/common/CommandPalette'; import { CommandPalette } from '@/components/common/CommandPalette';
import { import {
@@ -20,6 +20,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import Logo from '@/components/common/Logo'; import Logo from '@/components/common/Logo';
import { ThemeSwitcher } from '@/components/common/ThemeSwitcher';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
@@ -41,10 +42,6 @@ function getIcon(name: string): LucideIcons.LucideIcon {
export function TopBar() { export function TopBar() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); 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 toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setActiveSection = useUIStore((s) => s.setActiveSection); const setActiveSection = useUIStore((s) => s.setActiveSection);
const accounts = useAuthStore((s) => s.accounts); const accounts = useAuthStore((s) => s.accounts);
@@ -125,9 +122,7 @@ export function TopBar() {
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} /> <CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}> <ThemeSwitcher />
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -191,13 +186,6 @@ export function TopBar() {
</> </>
)} )}
<DropdownMenuItem onClick={toggleRadius}>
<Radius className="mr-2 h-4 w-4" />
{t('squareCorners', 'Square corners')}
{radius === 'square' && <Check className="ml-auto h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
+16 -2
View File
@@ -1,5 +1,21 @@
{ {
"accounts": "Accounts", "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": { "actions": {
"actionFailed": "Action failed.", "actionFailed": "Action failed.",
"backToActions": "Back to actions", "backToActions": "Back to actions",
@@ -337,8 +353,6 @@
"periodValue": "Period value" "periodValue": "Period value"
}, },
"sections": "Sections", "sections": "Sections",
"squareCorners": "Square corners",
"toggleTheme": "Toggle theme",
"tracing": { "tracing": {
"addFilter": "Add filter", "addFilter": "Add filter",
"bufferFull": "(buffer full)", "bufferFull": "(buffer full)",
+124
View File
@@ -96,6 +96,130 @@
--radius: 0px; --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 { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
+10 -9
View File
@@ -20,18 +20,19 @@ import { getBasePath } from './lib/basePath';
(() => { (() => {
try { try {
const persisted = localStorage.getItem('stalwart-ui'); const persisted = localStorage.getItem('stalwart-ui');
if (persisted) { const state = persisted ? (JSON.parse(persisted)?.state ?? null) : null;
const parsed = JSON.parse(persisted); const theme = state?.theme;
const theme = parsed?.state?.theme; const dark = theme === 'dark' || (theme !== 'light' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches);
if (theme === 'dark' || theme === 'light') { document.documentElement.classList.toggle('dark', dark);
document.documentElement.classList.toggle('dark', theme === 'dark'); const colorTheme = state?.colorTheme;
return; 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 // eslint-disable-next-line no-empty
} catch {} } catch {}
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', !!prefersDark);
})(); })();
const basePath = getBasePath(); const basePath = getBasePath();
+32 -14
View File
@@ -7,18 +7,19 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark'; export type Theme = 'light' | 'dark';
type Radius = 'rounded' | 'square'; export type ColorTheme = 'stalwart' | 'ocean' | 'forest' | 'violet';
export type Radius = 'rounded' | 'square';
interface UIState { interface UIState {
theme: Theme; theme: Theme;
colorTheme: ColorTheme;
radius: Radius; radius: Radius;
sidebarOpen: boolean; sidebarOpen: boolean;
activeSection: string; activeSection: string;
toggleTheme: () => void;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
toggleRadius: () => void; setColorTheme: (colorTheme: ColorTheme) => void;
setRadius: (radius: Radius) => void; setRadius: (radius: Radius) => void;
toggleSidebar: () => void; toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => 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) { function applyRadiusAttribute(radius: Radius) {
document.documentElement.dataset.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<UIState>()( export const useUIStore = create<UIState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
theme: theme:
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
colorTheme: 'stalwart',
radius: 'rounded', radius: 'rounded',
sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true, sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
activeSection: '', activeSection: '',
toggleTheme: () => {
const next = get().theme === 'light' ? 'dark' : 'light';
applyThemeClass(next);
set({ theme: next });
},
setTheme: (theme) => { setTheme: (theme) => {
applyThemeClass(theme); applyThemeClass(theme);
set({ theme }); set({ theme });
}, },
toggleRadius: () => { setColorTheme: (colorTheme) => {
const next = get().radius === 'rounded' ? 'square' : 'rounded'; applyColorThemeAttribute(colorTheme);
applyRadiusAttribute(next); set({ colorTheme });
set({ radius: next });
}, },
setRadius: (radius) => { setRadius: (radius) => {
@@ -84,12 +100,14 @@ export const useUIStore = create<UIState>()(
name: 'stalwart-ui', name: 'stalwart-ui',
partialize: (state) => ({ partialize: (state) => ({
theme: state.theme, theme: state.theme,
colorTheme: state.colorTheme,
radius: state.radius, radius: state.radius,
}), }),
onRehydrateStorage: () => { onRehydrateStorage: () => {
return (state) => { return (state) => {
if (state) { if (state) {
applyThemeClass(state.theme); applyThemeClass(state.theme);
applyColorThemeAttribute(state.colorTheme ?? 'stalwart');
applyRadiusAttribute(state.radius ?? 'rounded'); applyRadiusAttribute(state.radius ?? 'rounded');
} }
}; };