From a8c4dfd3722e3ae219102ed56a373665bc3c811a Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 21 Aug 2026 14:00:50 +0200 Subject: [PATCH] UI Customize: add custom-theme flag for default theme (#33042) --- assets/js/theme.ts | 2 +- assets/js/types/evcc.ts | 1 + cmd/flags.go | 3 +++ cmd/root.go | 1 + server/http.go | 7 +++++++ server/http_site_handler.go | 2 ++ tests/theme.spec.ts | 35 +++++++++++++++++++++++++++++++++++ 7 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/theme.spec.ts diff --git a/assets/js/theme.ts b/assets/js/theme.ts index 9c394ca37..1d4ce1ab0 100644 --- a/assets/js/theme.ts +++ b/assets/js/theme.ts @@ -9,7 +9,7 @@ export function getThemePreference(): THEME | null { if (theme && Object.values(THEME).includes(theme)) { return theme; } - return THEME.AUTO; + return window.evcc?.customTheme || THEME.AUTO; } export function setThemePreference(theme: THEME) { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index fbb031504..41c6704ad 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -14,6 +14,7 @@ declare global { customWebsite: string; customEmail: string; customPhone: string; + customTheme: THEME; }; } interface Window { diff --git a/cmd/flags.go b/cmd/flags.go index 827834c37..873188fea 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -46,6 +46,9 @@ const ( flagCustomPhone = "custom-phone" flagCustomPhoneDescription = "Support phone number shown in the UI" + flagCustomTheme = "custom-theme" + flagCustomThemeDescription = "Default UI theme (auto, light, dark). Users can override in the UI." + flagBatteryMode = "battery-mode" flagBatteryModeDescription = "Set battery mode (normal, hold, charge, holdcharge)" flagBatteryModeWait = "battery-mode-wait" diff --git a/cmd/root.go b/cmd/root.go index 670985ad9..ce309ab61 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -113,6 +113,7 @@ func init() { rootCmd.Flags().StringVar(&customization.Website, flagCustomWebsite, os.Getenv("EVCC_CUSTOM_WEBSITE"), flagCustomWebsiteDescription) rootCmd.Flags().StringVar(&customization.Email, flagCustomEmail, os.Getenv("EVCC_CUSTOM_EMAIL"), flagCustomEmailDescription) rootCmd.Flags().StringVar(&customization.Phone, flagCustomPhone, os.Getenv("EVCC_CUSTOM_PHONE"), flagCustomPhoneDescription) + rootCmd.Flags().StringVar(&customization.Theme, flagCustomTheme, os.Getenv("EVCC_CUSTOM_THEME"), flagCustomThemeDescription) } // initConfig reads in config file and ENV variables if set diff --git a/server/http.go b/server/http.go index c4725820b..ed41b811f 100644 --- a/server/http.go +++ b/server/http.go @@ -55,6 +55,7 @@ type Customization struct { Website string Email string Phone string + Theme string } // NewHTTPd creates HTTP server with configured routes for loadpoint @@ -112,6 +113,12 @@ func NewHTTPd(addr string, hub *SocketHub, custom Customization) *HTTPd { log.FATAL.Fatal("custom logo requires both light and dark variants") } + switch custom.Theme { + case "", "auto", "light", "dark": + default: + log.FATAL.Fatalf("invalid custom theme: %s (expected auto, light, dark)", custom.Theme) + } + for path, file := range map[string]string{ "/custom-logo-light": custom.LogoLight, "/custom-logo-dark": custom.LogoDark, diff --git a/server/http_site_handler.go b/server/http_site_handler.go index 2078ac3cd..4cc8bc958 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -59,6 +59,7 @@ func globalsJsHandler(custom Customization) http.HandlerFunc { Website string `json:"customWebsite"` Email string `json:"customEmail"` Phone string `json:"customPhone"` + Theme string `json:"customTheme"` }{ Version: util.Version, CustomCss: custom.Css != "", @@ -67,6 +68,7 @@ func globalsJsHandler(custom Customization) http.HandlerFunc { Website: custom.Website, Email: custom.Email, Phone: custom.Phone, + Theme: custom.Theme, } return func(w http.ResponseWriter, r *http.Request) { diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts new file mode 100644 index 000000000..911983ee1 --- /dev/null +++ b/tests/theme.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { openMoreMenu, expectModalVisible } from "./utils"; + +test.use({ baseURL: baseUrl() }); + +test.beforeAll(async () => { + await start("basics.evcc.yaml", null, ["--disable-auth", "--custom-theme", "dark"]); +}); + +test.afterAll(async () => { + await stop(); +}); + +test.describe("custom theme", async () => { + test("starts dark, user choice wins and survives reload", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("html")).toHaveAttribute("data-bs-theme", "dark"); + + const menu = await openMoreMenu(page); + await menu.getByRole("button", { name: "User Interface" }).click(); + const modal = page.getByTestId("global-settings-modal"); + await expectModalVisible(modal); + await expect(modal.getByRole("radio", { name: "dark" })).toHaveAttribute( + "aria-checked", + "true" + ); + + await modal.getByRole("radio", { name: "light" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-bs-theme", "light"); + + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-bs-theme", "light"); + }); +});