feat(theme): add custom UI color palettes

This commit is contained in:
Domye authored and GitHub committed 2026-08-16 11:59:28 +08:00
1 parent c81466d0db
commit 8996da7d9a
15 files changed
+618 -37

No files matched your search

+41
View File
@@ -107,4 +107,45 @@ describe("useTheme on Linux", () => {
await flushDynamicImport();
expect(setTheme).toHaveBeenLastCalledWith("light");
});
it("injects custom UI colors as inline CSS variables and removes them when leaving the custom palette", async () => {
const theme = await loadTheme();
theme.setThemePalette("custom");
await flushDynamicImport();
theme.setCustomUiColors({ ...theme.customUiColors.value, background: "#123456", primary: "#abcdef" });
expect(theme.themePalette.value).toBe("custom");
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(18 52 86)");
expect(document.documentElement.style.getPropertyValue("--primary")).toBe("rgb(171 205 239)");
expect(document.documentElement.style.getPropertyValue("--dbx-primary-rgb")).toBe("171, 205, 239");
expect(document.documentElement.style.getPropertyValue("--sidebar")).toBe("rgb(250 250 250)");
// Derived paired foregrounds are applied and readable against their surfaces.
expect(document.documentElement.style.getPropertyValue("--card-foreground")).toBe("rgb(10 10 10)");
expect(document.documentElement.style.getPropertyValue("--primary-foreground")).toBe("rgb(10 10 10)");
theme.setThemePalette("pearl");
await flushDynamicImport();
expect(document.documentElement.style.getPropertyValue("--background")).toBe("");
expect(document.documentElement.style.getPropertyValue("--dbx-primary-rgb")).toBe("");
});
it("applies a separate custom color set in dark mode and switches sets with the mode", async () => {
const theme = await loadTheme("dark");
theme.setThemePalette("custom");
await flushDynamicImport();
// Dark mode uses the dark custom set by default (not the light defaults).
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(19 20 22)");
expect(document.documentElement.style.getPropertyValue("--foreground")).toBe("rgb(215 215 219)");
theme.setCustomUiColors({ ...theme.customUiColorsDark.value, background: "#1a1b1e" });
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(26 27 30)");
// Switching to light mode re-applies the light custom set.
theme.setThemeMode("light");
await flushDynamicImport();
expect(document.documentElement.style.getPropertyValue("--background")).toBe("rgb(255 255 255)");
expect(theme.customUiColors.value.background).toBe("#ffffff");
expect(theme.customUiColorsDark.value.background).toBe("#1a1b1e");
});
});
+62 -1
View File
@@ -1,16 +1,26 @@
import { computed, ref } from "vue";
import {
APP_CORNER_STYLE_STORAGE_KEY,
APP_CUSTOM_UI_COLOR_DEFS,
APP_CUSTOM_UI_DARK_STORAGE_KEY,
APP_CUSTOM_UI_DERIVED_VAR_NAMES,
APP_CUSTOM_UI_STORAGE_KEY,
deriveCustomUiColors,
APP_THEME_PALETTE_CLASS_NAMES,
APP_THEME_PALETTE_STORAGE_KEY,
APP_THEME_STORAGE_KEY,
DEFAULT_APP_CUSTOM_UI_COLORS,
DEFAULT_APP_CUSTOM_UI_COLORS_DARK,
appCustomUiColorValue,
getAppThemePaletteClass,
getTauriThemeForMode,
isSystemAppThemeMode,
normalizeAppCustomUiColors,
normalizeAppThemeMode,
normalizeAppCornerStyle,
normalizeAppThemePalette,
resolveAppThemeAppearance,
type AppCustomUiColors,
type AppThemeMode,
type AppThemePalette,
type AppCornerStyle,
@@ -26,6 +36,17 @@ const savedThemeMode = safeLocalStorageGet(APP_THEME_STORAGE_KEY);
const themeMode = ref<AppThemeMode>(normalizeAppThemeMode(savedThemeMode));
const savedThemePalette = safeLocalStorageGet(APP_THEME_PALETTE_STORAGE_KEY);
const themePalette = ref<AppThemePalette>(normalizeAppThemePalette(savedThemePalette));
function parseStoredCustomUiColors(raw: string | null): AppCustomUiColors | null {
if (!raw) return null;
try {
return normalizeAppCustomUiColors(JSON.parse(raw));
} catch {
return null;
}
}
const customUiColors = ref<AppCustomUiColors>(parseStoredCustomUiColors(safeLocalStorageGet(APP_CUSTOM_UI_STORAGE_KEY)) ?? DEFAULT_APP_CUSTOM_UI_COLORS);
const customUiColorsDark = ref<AppCustomUiColors>(parseStoredCustomUiColors(safeLocalStorageGet(APP_CUSTOM_UI_DARK_STORAGE_KEY)) ?? DEFAULT_APP_CUSTOM_UI_COLORS_DARK);
const activeCustomUiColors = computed<AppCustomUiColors>(() => (isDark.value ? customUiColorsDark.value : customUiColors.value));
const savedCornerStyle = safeLocalStorageGet(APP_CORNER_STYLE_STORAGE_KEY);
const cornerStyle = ref<AppCornerStyle>(normalizeAppCornerStyle(savedCornerStyle));
if (savedThemeMode && savedThemeMode !== themeMode.value) safeLocalStorageSet(APP_THEME_STORAGE_KEY, themeMode.value);
@@ -67,6 +88,7 @@ function applyTheme() {
if (paletteClass) doc.classList.add(paletteClass);
doc.dataset.cornerStyle = cornerStyle.value;
doc.style.colorScheme = dark ? "dark" : "light";
applyCustomUiColors();
// force reflow so the class toggle takes effect before re-enabling transitions
doc.offsetHeight; // eslint-disable-line @typescript-eslint/no-unused-expressions
@@ -106,6 +128,45 @@ function setThemePalette(palette: AppThemePalette) {
applyTheme();
}
function applyCustomUiColors() {
if (typeof document === "undefined") return;
const doc = document.documentElement;
const clear = () => {
for (const def of APP_CUSTOM_UI_COLOR_DEFS) {
doc.style.removeProperty(def.varName);
if (def.rgbVarName) doc.style.removeProperty(def.rgbVarName);
}
for (const name of APP_CUSTOM_UI_DERIVED_VAR_NAMES) doc.style.removeProperty(name);
};
if (themePalette.value !== "custom") {
clear();
return;
}
for (const def of APP_CUSTOM_UI_COLOR_DEFS) {
const { color, rgbTriplet } = appCustomUiColorValue(activeCustomUiColors.value[def.key]);
doc.style.setProperty(def.varName, color);
if (def.rgbVarName) doc.style.setProperty(def.rgbVarName, rgbTriplet);
}
const derived = deriveCustomUiColors(activeCustomUiColors.value);
for (const name of APP_CUSTOM_UI_DERIVED_VAR_NAMES) doc.style.setProperty(name, derived[name]);
}
function setCustomUiColors(colors: AppCustomUiColors) {
const next = normalizeAppCustomUiColors(colors);
if (isDark.value) {
customUiColorsDark.value = next;
safeLocalStorageSet(APP_CUSTOM_UI_DARK_STORAGE_KEY, JSON.stringify(next));
} else {
customUiColors.value = next;
safeLocalStorageSet(APP_CUSTOM_UI_STORAGE_KEY, JSON.stringify(next));
}
applyCustomUiColors();
}
function resetCustomUiColors() {
setCustomUiColors({ ...(isDark.value ? DEFAULT_APP_CUSTOM_UI_COLORS_DARK : DEFAULT_APP_CUSTOM_UI_COLORS) });
}
function setCornerStyle(style: AppCornerStyle) {
cornerStyle.value = normalizeAppCornerStyle(style);
safeLocalStorageSet(APP_CORNER_STYLE_STORAGE_KEY, cornerStyle.value);
@@ -119,5 +180,5 @@ export function useTheme() {
setThemeMode(isDark.value ? "light" : "dark");
}
return { isDark, themeMode, themePalette, cornerStyle, applyTheme, setThemeMode, setThemePalette, setCornerStyle, toggleTheme };
return { isDark, themeMode, themePalette, customUiColors, customUiColorsDark, activeCustomUiColors, cornerStyle, applyTheme, setThemeMode, setThemePalette, setCustomUiColors, resetCustomUiColors, setCornerStyle, toggleTheme };
}