feat: add auto/system color scheme option

- Extend ColorScheme type to include 'auto' alongside 'light' and 'dark'
- Allow 'auto' to persist to DB and cookie (remove the explicit guard)
- Server default and new-user default both changed to 'auto'
- layout.tsx: omit inline backgroundColor when scheme is 'auto'
- Avatar menu: replace toggle item with a Combobox (Auto/Light/Dark)
  mirroring the existing language selector pattern
- Appearance settings form and color scheme comboboxes updated with
  IconDeviceDesktop for the new auto option
- Spotlight command handles 'auto' state via useComputedColorScheme
- Old import: map legacy 'environment' value to 'auto'
- Add translation keys for 'auto' in all three relevant scopes
This commit is contained in:
Kyle Grieder committed 2026-04-12 13:30:21 -06:00
1 parent 327611dfc6
commit 0d2675e1bb
12 files changed
+124 -67

No files matched your search

@@ -8,13 +8,12 @@ import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
const manager = useColorSchemeManager();
return (
<DirectionProvider>
@@ -35,7 +34,7 @@ export const CustomMantineProvider = ({
export function useColorSchemeManager(): MantineColorSchemeManager {
const { data: session } = useSession();
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => {
const updateCookieValue = (value: MantineColorScheme) => {
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
};
@@ -60,7 +59,6 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
},
set: (value) => {
if (value === "auto") return;
try {
if (session) {
mutateColorScheme({ colorScheme: value });
+1 -1
View File
@@ -129,7 +129,7 @@ export default async function Layout(props: {
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
}}
suppressHydrationWarning
>
@@ -1,7 +1,7 @@
"use client";
import { Group, Text } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
@@ -34,6 +34,7 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
};
const appearanceIcons = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
@@ -0,0 +1,94 @@
"use client";
import React from "react";
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
import { IconCheck, IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
const schemeIcons: Record<ColorScheme, React.ElementType> = {
auto: IconDeviceDesktop,
light: IconSun,
dark: IconMoon,
};
interface ColorSchemeComboboxProps {
value: ColorScheme;
onChange: (value: ColorScheme) => void;
width?: string;
}
export const ColorSchemeCombobox = ({ value, onChange, width }: ColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const handleOnOptionSubmit = React.useCallback(
(val: string) => {
if (!val) return;
onChange(val as ColorScheme);
combobox.closeDropdown();
},
[onChange, combobox],
);
const handleOnClick = React.useCallback(() => {
combobox.toggleDropdown();
}, [combobox]);
return (
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
<Combobox.Target>
<InputBase
component="button"
type="button"
pointer
rightSection={<Combobox.Chevron />}
rightSectionPointerEvents="none"
onClick={handleOnClick}
variant="filled"
w={width}
>
<OptionItem scheme={value} currentScheme={value} tOptions={tOptions} />
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{colorSchemes.map((scheme) => (
<Combobox.Option value={scheme} key={scheme}>
<OptionItem scheme={scheme} currentScheme={value} tOptions={tOptions} showCheck />
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
const OptionItem = ({
scheme,
currentScheme,
tOptions,
showCheck,
}: {
scheme: ColorScheme;
currentScheme: ColorScheme;
tOptions: (key: ColorScheme) => string;
showCheck?: boolean;
}) => {
const Icon = schemeIcons[scheme];
return (
<Group wrap="nowrap" justify="space-between">
<Group wrap="nowrap">
<Icon size={16} stroke={1.5} />
<Text>{tOptions(scheme)}</Text>
</Group>
{showCheck && scheme === currentScheme && <IconCheck color="currentColor" size={16} />}
</Group>
);
};
@@ -1,49 +1,17 @@
"use client";
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import { useMantineColorScheme } from "@mantine/core";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { ColorSchemeCombobox } from "./color-scheme-combobox";
interface CurrentColorSchemeComboboxProps {
w?: string;
}
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const { colorScheme, setColorScheme } = useMantineColorScheme();
return (
<SelectWithCustomItems
value={colorScheme}
onChange={(value) => setColorScheme((value as ColorScheme | null) ?? "light")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tOptions(scheme),
}))}
SelectOption={ColorSchemeCustomOption}
w={w}
/>
);
};
const appearanceIcons = {
light: IconSun,
dark: IconMoon,
};
const ColorSchemeCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
<Group>
<Icon size={16} stroke={1.5} />
<Text fz="sm" fw={500}>
{label}
</Text>
</Group>
);
return <ColorSchemeCombobox value={colorScheme as ColorScheme} onChange={setColorScheme} width={w} />;
};
@@ -5,16 +5,7 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import {
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconMoon,
IconSettings,
IconSun,
IconTool,
} from "@tabler/icons-react";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -24,6 +15,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { AvailableUpdatesMenuItem } from "./layout/header/update";
@@ -34,13 +26,9 @@ interface UserAvatarMenuProps {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
const ColorSchemeIcon = colorScheme === "dark" ? IconSun : IconMoon;
const colorSchemeText = colorScheme === "dark" ? t("switchToLightMode") : t("switchToDarkMode");
const session = useSession();
const router = useRouter();
@@ -67,14 +55,14 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
<Menu width={300} withArrow withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText}
</Menu.Item>
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
+1 -1
View File
@@ -36,7 +36,7 @@ export const createSessionAsync = async (
...user,
email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "dark",
colorScheme: "auto",
},
} as Session;
};
+1 -1
View File
@@ -1,2 +1,2 @@
export const colorSchemes = ["light", "dark"] as const;
export const colorSchemes = ["auto", "light", "dark"] as const;
export type ColorScheme = (typeof colorSchemes)[number];
+1 -1
View File
@@ -24,7 +24,7 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
oldId: id,
id: createId(),
name: user.name.toLowerCase(),
colorScheme: settings?.colorScheme === "environment" ? undefined : settings?.colorScheme,
colorScheme: settings?.colorScheme === "environment" ? "auto" : settings?.colorScheme,
firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6,
provider: "credentials",
pingIconsEnabled: settings?.replacePingWithIcons,
+1 -1
View File
@@ -36,7 +36,7 @@ export const defaultServerSettings = {
enableGravatar: true,
},
appearance: {
defaultColorScheme: "light" as ColorScheme,
defaultColorScheme: "auto" as ColorScheme,
},
culture: {
defaultLocale: "en" as SupportedLanguage,
@@ -1,7 +1,8 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
import {
IconBox,
IconCategoryPlus,
IconDeviceDesktop,
IconFileImport,
IconLanguage,
IconMailForward,
@@ -52,13 +53,17 @@ export const globalCommandGroup = createGroup<Command>({
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "dark" ? IconSun : IconMoon,
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon,
name:
colorScheme === "auto"
? tOption("colorScheme.auto")
: tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
+3
View File
@@ -1080,6 +1080,7 @@
},
"colorScheme": {
"options": {
"auto": "Auto",
"light": "Light",
"dark": "Dark"
}
@@ -3568,6 +3569,7 @@
"defaultColorScheme": {
"label": "Default color scheme",
"options": {
"auto": "Auto",
"light": "Light",
"dark": "Dark"
}
@@ -4376,6 +4378,7 @@
"title": "Global commands",
"option": {
"colorScheme": {
"auto": "Toggle appearance mode",
"light": "Switch to light mode",
"dark": "Switch to dark mode"
},