Compare commits

...
Author SHA1 Message Date
Thomas 190e30dcf5 Merge branch 'dev' into feat/appearance-updates 2026-05-07 17:52:30 +02:00
Kyle bd99c2cfaf Merge branch 'dev' into feat/appearance-updates 2026-05-04 17:58:31 -06:00
Kyle cc7e5ddcb5 Merge branch 'dev' into feat/appearance-updates 2026-04-23 23:45:53 -06:00
Kyle 4eacce3e0b Merge branch 'dev' into feat/appearance-updates 2026-04-23 12:26:52 -06:00
Kyle 3b44d13791 Merge branch 'dev' into feat/appearance-updates 2026-04-21 23:35:29 -06:00
Kyle Grieder d809398e64 fix: prevent white flash on page load when color scheme is auto 2026-04-21 22:45:35 -06:00
Kyle 3199a7f242 Merge branch 'dev' into feat/appearance-updates 2026-04-21 21:40:37 -06:00
Kyle 748f59f444 Merge branch 'dev' into feat/appearance-updates 2026-04-14 13:29:45 -06:00
Kyle ab51d2f274 Merge branch 'dev' into feat/appearance-updates 2026-04-13 14:31:50 -06:00
Kyle f5b3c5c442 Merge branch 'dev' into feat/appearance-updates 2026-04-12 20:04:15 -06:00
Kyle 7151abdb88 Merge branch 'dev' into feat/appearance-updates 2026-04-12 13:31:34 -06:00
Kyle Grieder 76154b5752 fix: use computed color scheme in system resources charts
useMantineColorScheme() returns 'auto' when system mode is active,
causing the chart background color to fall through to the light variant.
Switch to useComputedColorScheme() which always resolves to the actual
'light' or 'dark' value, keeping charts visually correct in all modes.
2026-04-12 13:30:28 -06:00
Kyle Grieder 0d2675e1bb 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
2026-04-12 13:30:21 -06:00
14 changed files with 140 additions and 71 deletions

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 });
+2 -1
View File
@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
@@ -129,7 +130,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>
+12
View File
@@ -0,0 +1,12 @@
// Prevent flash of white background when color scheme is "auto" and OS prefers dark.
// CSS media queries are evaluated synchronously during the browser's initial paint,
// before any JavaScript executes, so this eliminates the white→dark flicker.
html[data-mantine-color-scheme="auto"] {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
html[data-mantine-color-scheme="auto"] {
background-color: #242424;
}
}
+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"
}
@@ -3666,6 +3667,7 @@
"defaultColorScheme": {
"label": "Default color scheme",
"options": {
"auto": "Auto",
"light": "Light",
"dark": "Dark"
}
@@ -4481,6 +4483,7 @@
"title": "Global commands",
"option": {
"colorScheme": {
"auto": "Toggle appearance mode",
"light": "Switch to light mode",
"dark": "Switch to dark mode"
},
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import { Card, Center, Group, Loader, Stack, Text, useMantineColorScheme, useMantineTheme } from "@mantine/core";
import { Card, Center, Group, Loader, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts";
@@ -36,14 +36,13 @@ export const CommonChart = ({
}) => {
const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme();
const scheme = useMantineColorScheme();
const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard();
const { hovered, ref: hoverRef } = useHover();
const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100;
const backgroundColor =
scheme.colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";