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 { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common"; import { parseCookies, setClientCookie } from "@homarr/common";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions"; import { colorSchemeCookieKey } from "@homarr/definitions";
export const CustomMantineProvider = ({ export const CustomMantineProvider = ({
children, children,
defaultColorScheme, defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => { }: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
const manager = useColorSchemeManager(); const manager = useColorSchemeManager();
return ( return (
<DirectionProvider> <DirectionProvider>
@@ -35,7 +34,7 @@ export const CustomMantineProvider = ({
export function useColorSchemeManager(): MantineColorSchemeManager { export function useColorSchemeManager(): MantineColorSchemeManager {
const { data: session } = useSession(); const { data: session } = useSession();
const updateCookieValue = (value: Exclude<MantineColorScheme, "auto">) => { const updateCookieValue = (value: MantineColorScheme) => {
setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" }); setClientCookie(colorSchemeCookieKey, value, { expires: dayjs().add(1, "year").toDate(), path: "/" });
}; };
@@ -60,7 +59,6 @@ export function useColorSchemeManager(): MantineColorSchemeManager {
}, },
set: (value) => { set: (value) => {
if (value === "auto") return;
try { try {
if (session) { if (session) {
mutateColorScheme({ colorScheme: value }); mutateColorScheme({ colorScheme: value });
+2 -1
View File
@@ -4,6 +4,7 @@ import { Inter } from "next/font/google";
import "@homarr/notifications/styles.css"; import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css"; import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css"; import "@homarr/ui/styles.css";
import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss"; import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -129,7 +130,7 @@ export default async function Layout(props: {
dir={direction} dir={direction}
data-mantine-color-scheme={colorScheme} data-mantine-color-scheme={colorScheme}
style={{ style={{
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff", backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
}} }}
suppressHydrationWarning suppressHydrationWarning
> >
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Group, Text } from "@mantine/core"; 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 type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions"; import { colorSchemes } from "@homarr/definitions";
@@ -34,6 +34,7 @@ export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: Serve
}; };
const appearanceIcons = { const appearanceIcons = {
auto: IconDeviceDesktop,
light: IconSun, light: IconSun,
dark: IconMoon, 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"; "use client";
import { Group, Text, useMantineColorScheme } from "@mantine/core"; import { useMantineColorScheme } from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client"; import { ColorSchemeCombobox } from "./color-scheme-combobox";
import { SelectWithCustomItems } from "@homarr/ui";
interface CurrentColorSchemeComboboxProps { interface CurrentColorSchemeComboboxProps {
w?: string; w?: string;
} }
export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => { export const CurrentColorSchemeCombobox = ({ w }: CurrentColorSchemeComboboxProps) => {
const tOptions = useScopedI18n("common.colorScheme.options");
const { colorScheme, setColorScheme } = useMantineColorScheme(); const { colorScheme, setColorScheme } = useMantineColorScheme();
return ( return <ColorSchemeCombobox value={colorScheme as ColorScheme} onChange={setColorScheme} width={w} />;
<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>
);
}; };
@@ -5,16 +5,7 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core"; import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks"; import { useHotkeys, useTimeout } from "@mantine/hooks";
import { import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconMoon,
IconSettings,
IconSun,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client"; import { signOut, useSession } from "@homarr/auth/client";
@@ -24,6 +15,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session"; 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 { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { AvailableUpdatesMenuItem } from "./layout/header/update"; import { AvailableUpdatesMenuItem } from "./layout/header/update";
@@ -34,13 +26,9 @@ interface UserAvatarMenuProps {
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => { export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu"); const t = useScopedI18n("common.userAvatar.menu");
const { colorScheme, toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]); useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
const ColorSchemeIcon = colorScheme === "dark" ? IconSun : IconMoon;
const colorSchemeText = colorScheme === "dark" ? t("switchToLightMode") : t("switchToDarkMode");
const session = useSession(); const session = useSession();
const router = useRouter(); const router = useRouter();
@@ -67,14 +55,14 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
<Menu width={300} withArrow withinPortal keepMounted> <Menu width={300} withArrow withinPortal keepMounted>
<Menu.Dropdown> <Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} /> <AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
{colorSchemeText}
</Menu.Item>
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}> <Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")} {t("homeBoard")}
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div"> <Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox /> <CurrentLanguageCombobox />
</Menu.Item> </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, ...user,
email: user.email ?? "", email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id), permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "dark", colorScheme: "auto",
}, },
} as Session; } 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]; export type ColorScheme = (typeof colorSchemes)[number];
+1 -1
View File
@@ -24,7 +24,7 @@ export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionT
oldId: id, oldId: id,
id: createId(), id: createId(),
name: user.name.toLowerCase(), 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, firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6,
provider: "credentials", provider: "credentials",
pingIconsEnabled: settings?.replacePingWithIcons, pingIconsEnabled: settings?.replacePingWithIcons,
+1 -1
View File
@@ -36,7 +36,7 @@ export const defaultServerSettings = {
enableGravatar: true, enableGravatar: true,
}, },
appearance: { appearance: {
defaultColorScheme: "light" as ColorScheme, defaultColorScheme: "auto" as ColorScheme,
}, },
culture: { culture: {
defaultLocale: "en" as SupportedLanguage, defaultLocale: "en" as SupportedLanguage,
@@ -1,7 +1,8 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core"; import { Group, Text, useComputedColorScheme, useMantineColorScheme } from "@mantine/core";
import { import {
IconBox, IconBox,
IconCategoryPlus, IconCategoryPlus,
IconDeviceDesktop,
IconFileImport, IconFileImport,
IconLanguage, IconLanguage,
IconMailForward, IconMailForward,
@@ -52,13 +53,17 @@ export const globalCommandGroup = createGroup<Command>({
useOptions() { useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option"); const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const { data: session } = useSession(); const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [ const commands: (Command & { hidden?: boolean })[] = [
{ {
commandKey: "colorScheme", commandKey: "colorScheme",
icon: colorScheme === "dark" ? IconSun : IconMoon, icon: colorScheme === "auto" ? IconDeviceDesktop : computedColorScheme === "dark" ? IconSun : IconMoon,
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`), name:
colorScheme === "auto"
? tOption("colorScheme.auto")
: tOption(`colorScheme.${computedColorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => { useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
+3
View File
@@ -1080,6 +1080,7 @@
}, },
"colorScheme": { "colorScheme": {
"options": { "options": {
"auto": "Auto",
"light": "Light", "light": "Light",
"dark": "Dark" "dark": "Dark"
} }
@@ -3666,6 +3667,7 @@
"defaultColorScheme": { "defaultColorScheme": {
"label": "Default color scheme", "label": "Default color scheme",
"options": { "options": {
"auto": "Auto",
"light": "Light", "light": "Light",
"dark": "Dark" "dark": "Dark"
} }
@@ -4481,6 +4483,7 @@
"title": "Global commands", "title": "Global commands",
"option": { "option": {
"colorScheme": { "colorScheme": {
"auto": "Toggle appearance mode",
"light": "Switch to light mode", "light": "Switch to light mode",
"dark": "Switch to dark mode" "dark": "Switch to dark mode"
}, },
@@ -2,7 +2,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { AreaChartSeries } from "@mantine/charts"; import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } 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 { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts"; import type { TooltipProps, YAxisProps } from "recharts";
@@ -36,14 +36,13 @@ export const CommonChart = ({
}) => { }) => {
const { ref: elementSizeRef, height } = useElementSize(); const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme(); const theme = useMantineTheme();
const scheme = useMantineColorScheme(); const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hovered, ref: hoverRef } = useHover(); const { hovered, ref: hoverRef } = useHover();
const ref = useMergedRef(elementSizeRef, hoverRef); const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100; const opacity = board.opacity / 100;
const backgroundColor = const backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
scheme.colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const ChartComponent = chartType === "line" ? LineChart : AreaChart; const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";