From 8996da7d9a54d788e659729cdfbd3530e739448b Mon Sep 17 00:00:00 2001
From: Domye <67504754+domye@users.noreply.github.com>
Date: Sun, 16 Aug 2026 11:59:28 +0800
Subject: [PATCH] feat(theme): add custom UI color palettes
---
.../editor/EditorSettingsDialog.vue | 75 ++++--
.../src/components/editor/QueryEditor.vue | 24 +-
.../composables/__tests__/useTheme.spec.ts | 41 ++++
apps/desktop/src/composables/useTheme.ts | 63 ++++-
apps/desktop/src/i18n/locales/en.ts | 9 +
apps/desktop/src/i18n/locales/es.ts | 9 +
apps/desktop/src/i18n/locales/it.ts | 9 +
apps/desktop/src/i18n/locales/ja.ts | 9 +
apps/desktop/src/i18n/locales/pt-BR.ts | 9 +
apps/desktop/src/i18n/locales/zh-CN.ts | 9 +
apps/desktop/src/i18n/locales/zh-TW.ts | 9 +
.../src/lib/__tests__/editorThemes.spec.ts | 81 ++++++-
.../src/lib/app/__tests__/appTheme.spec.ts | 63 +++++
apps/desktop/src/lib/app/appTheme.ts | 216 +++++++++++++++++-
apps/desktop/src/lib/editor/editorThemes.ts | 29 ++-
15 files changed, 618 insertions(+), 37 deletions(-)
create mode 100644 apps/desktop/src/lib/app/__tests__/appTheme.spec.ts
diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
index 10f6576cc3..e4208f4241 100644
--- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue
+++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
@@ -125,7 +125,7 @@ import ChangelogPanel from "@/components/settings/ChangelogPanel.vue";
import McpConnectionScopePicker from "@/components/settings/McpConnectionScopePicker.vue";
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
-import { APP_THEME_PALETTES, type AppCornerStyle, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
+import { APP_CUSTOM_UI_COLOR_DEFS, APP_THEME_PALETTES, type AppCornerStyle, type AppCustomUiColors, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
import { editorSettingsDraftChanged, editorSettingsDraftFromSettings, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -152,7 +152,11 @@ const connectionStore = useConnectionStore();
const savedSqlStore = useSavedSqlStore();
const promptTemplateStore = usePromptTemplateStore();
const tunnelProfileStore = useTunnelProfileStore();
-const { isDark, themeMode, themePalette, cornerStyle, setThemeMode, setThemePalette, setCornerStyle } = useTheme();
+const { isDark, themeMode, themePalette, activeCustomUiColors, cornerStyle, setThemeMode, setThemePalette, setCustomUiColors, resetCustomUiColors, setCornerStyle } = useTheme();
+
+function updateCustomUiColor(key: keyof AppCustomUiColors, value: string) {
+ setCustomUiColors({ ...activeCustomUiColors.value, [key]: value });
+}
const appThemePaletteOptions = computed(
(): Array<{ value: AppThemePalette; label: string; previewColor: string }> =>
@@ -4157,29 +4161,36 @@ onUnmounted(() => {
@@ -4208,6 +4219,20 @@ onUnmounted(() => {
+
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue
index 4df24c3f5f..c8271d1f19 100644
--- a/apps/desktop/src/components/editor/QueryEditor.vue
+++ b/apps/desktop/src/components/editor/QueryEditor.vue
@@ -100,7 +100,7 @@ import {
type QueryEditorTableReferencePayload,
} from "@/lib/editor/queryEditorTableDrop";
import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter";
-import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
+import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes";
import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter";
import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap";
import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap";
@@ -225,7 +225,7 @@ function sqlStatementParameterOptions() {
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, props.databaseType, settingsStore.editorSettings.sqlVariableSubstitutionEnabled);
return { databaseType: props.databaseType, enabledSyntaxes: enabledSqlParameterSyntaxes(toggles) };
}
-const { isDark, themePalette } = useTheme();
+const { isDark, themePalette, activeCustomUiColors } = useTheme();
const { t } = useI18n();
const { toast } = useToast();
const snippetDatabaseType = computed(() => {
@@ -595,7 +595,7 @@ const tableNavigationHoverClass = "query-editor--table-navigation-hover";
const DBX_VIM_SAVE_EVENT = "dbx-vim-save";
function editorThemeAppearance() {
- return isDark.value ? "dark" : "light";
+ return editorThemeAppearanceFor(isDark.value ? "dark" : "light", themePalette.value, themePalette.value === "custom" ? activeCustomUiColors.value : undefined);
}
// Completion cache
@@ -681,6 +681,16 @@ function syncEditorFontCssVars(fontSize = liveFontSize.value, fontFamily = setti
editorRef.value.style.setProperty(EDITOR_FONT_FAMILY_CSS_VAR, fontFamily);
}
+// Diagnostics render on the editor surface, so their marker colors follow the
+// resolved editor appearance (which already adapts to custom backgrounds) via
+// editor-scoped variables instead of the app-level warning/destructive tokens.
+function syncEditorDiagnosticCssVars() {
+ if (!editorRef.value) return;
+ const colors = editorDiagnosticColors(editorThemeAppearance());
+ editorRef.value.style.setProperty("--dbx-editor-diagnostic-error", colors.error);
+ editorRef.value.style.setProperty("--dbx-editor-diagnostic-warning", colors.warning);
+}
+
let pendingFontReconfig: { size: number; family: string } | null = null;
let fontReconfigScheduled = false;
@@ -4488,11 +4498,11 @@ onMounted(async () => {
const diagnosticTheme = EditorView.baseTheme({
".cm-sql-error": {
- textDecoration: "underline wavy var(--destructive)",
+ textDecoration: "underline wavy var(--dbx-editor-diagnostic-error, var(--destructive))",
textUnderlineOffset: "3px",
},
".cm-sql-semantic-warning": {
- textDecoration: "underline wavy var(--warning)",
+ textDecoration: "underline wavy var(--dbx-editor-diagnostic-warning, var(--warning))",
textUnderlineOffset: "3px",
},
});
@@ -5309,6 +5319,7 @@ onMounted(async () => {
restoreEditorViewport();
syncContextMenuState(view.value);
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
+ syncEditorDiagnosticCssVars();
registerTableReferenceDropListener();
cachedTables = [];
@@ -5476,7 +5487,7 @@ function getCurrentCustomThemeColors() {
// Reactively apply editor settings changes
watch(
- [queryEditorAppearanceSettings, () => isDark.value, () => themePalette.value],
+ [queryEditorAppearanceSettings, () => isDark.value, () => themePalette.value, editorThemeAppearance],
async ([ss]) => {
if (!view.value || !codeMirrorTheme || !fontThemeComp || !wordWrapComp || !vimModeComp || !closeBracketsComp || !runGutterComp || !runKeymapComp || !editorViewModule) {
return;
@@ -5485,6 +5496,7 @@ watch(
liveFontSize.value = ss.fontSize;
}
syncEditorFontCssVars(liveFontSize.value, ss.fontFamily);
+ syncEditorDiagnosticCssVars();
const themeColors = getCurrentCustomThemeColors();
const [themeExt] = await Promise.all([loadEditorTheme(ss.theme, editorThemeAppearance(), themeColors, themePalette.value), ss.vimModeEnabled ? ensureCodeMirrorVim() : Promise.resolve(false)]);
if (!view.value || !codeMirrorTheme || !wordWrapComp || !vimModeComp || !closeBracketsComp || !runGutterComp || !runKeymapComp || !editorViewModule) {
diff --git a/apps/desktop/src/composables/__tests__/useTheme.spec.ts b/apps/desktop/src/composables/__tests__/useTheme.spec.ts
index 405af68168..efaf5adf85 100644
--- a/apps/desktop/src/composables/__tests__/useTheme.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useTheme.spec.ts
@@ -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");
+ });
});
diff --git a/apps/desktop/src/composables/useTheme.ts b/apps/desktop/src/composables/useTheme.ts
index 7ee9093c61..7c91c8d172 100644
--- a/apps/desktop/src/composables/useTheme.ts
+++ b/apps/desktop/src/composables/useTheme.ts
@@ -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
(normalizeAppThemeMode(savedThemeMode));
const savedThemePalette = safeLocalStorageGet(APP_THEME_PALETTE_STORAGE_KEY);
const themePalette = ref(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(parseStoredCustomUiColors(safeLocalStorageGet(APP_CUSTOM_UI_STORAGE_KEY)) ?? DEFAULT_APP_CUSTOM_UI_COLORS);
+const customUiColorsDark = ref(parseStoredCustomUiColors(safeLocalStorageGet(APP_CUSTOM_UI_DARK_STORAGE_KEY)) ?? DEFAULT_APP_CUSTOM_UI_COLORS_DARK);
+const activeCustomUiColors = computed(() => (isDark.value ? customUiColorsDark.value : customUiColors.value));
const savedCornerStyle = safeLocalStorageGet(APP_CORNER_STYLE_STORAGE_KEY);
const cornerStyle = ref(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 };
}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index ff1a9db7c3..f49c104b1d 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -5730,6 +5730,15 @@ export default {
themePaletteJetbrains: "JetBrains",
themePaletteCursor: "Cursor",
themePaletteClaude: "Claude Code",
+ themePaletteCustom: "Custom",
+ customUiReset: "Reset to defaults",
+ customUiEditingLight: "Editing light-mode colors",
+ customUiEditingDark: "Editing dark-mode colors",
+ customUiBackground: "Background",
+ customUiForeground: "Foreground",
+ customUiPrimary: "Primary",
+ customUiBorder: "Border",
+ customUiSidebar: "Sidebar",
followAppTheme: "Follow app theme",
appLayout: "Interface Layout",
appLayoutSeparated: "Modern separated",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index e0123bca2f..4583cf3944 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -6040,6 +6040,15 @@ export default withEnglishFallback({
sidebarIndentDescription: "Desplazamiento en píxeles de la sangría para cada nivel del árbol.",
sidebarFontSize: "Tamaño de fuente de la barra lateral",
sidebarFontSizeDescription: "Tamaño de fuente (en píxeles) del árbol de objetos de la barra lateral, independiente del tamaño de fuente del editor.",
+ themePaletteCustom: "Personalizado",
+ customUiReset: "Restablecer predeterminados",
+ customUiEditingLight: "Editando: Colores claros",
+ customUiEditingDark: "Editando: Colores oscuros",
+ customUiBackground: "Fondo",
+ customUiForeground: "Texto de primer plano",
+ customUiPrimary: "Color principal",
+ customUiBorder: "Borde",
+ customUiSidebar: "Barra lateral",
},
driverStore: {
jreDirRemoveFailed: "No se pudo eliminar el directorio JRE antiguo: {path} (error original: {error})",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 2ee9c8d33c..4f8f228280 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -6040,6 +6040,15 @@ export default withEnglishFallback({
sidebarIndentDescription: "Offset in pixel per l'indentazione di ogni livello dell'albero.",
sidebarFontSize: "Dimensione del carattere della barra laterale",
sidebarFontSizeDescription: "Dimensione del carattere (in pixel) per l'albero degli oggetti della barra laterale, indipendente dalla dimensione del carattere dell'editor.",
+ themePaletteCustom: "Personalizzato",
+ customUiReset: "Ripristina predefiniti",
+ customUiEditingLight: "Modifica in corso: tema chiaro",
+ customUiEditingDark: "Modifica in corso: tema scuro",
+ customUiBackground: "Sfondo",
+ customUiForeground: "Testo in primo piano",
+ customUiPrimary: "Colore principale",
+ customUiBorder: "Bordo",
+ customUiSidebar: "Barra laterale",
},
driverStore: {
jreDirRemoveFailed: "Impossibile rimuovere la vecchia directory JRE: {path} (errore originale: {error})",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 03632d64a5..d9f1171d94 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -6093,6 +6093,15 @@ export default withEnglishFallback({
sidebarIndentDescription: "各ツリー階層のインデントのピクセルオフセット。",
sidebarFontSize: "サイドバーのフォントサイズ",
sidebarFontSizeDescription: "サイドバーのオブジェクトツリーのフォントサイズ(ピクセル)。エディターのフォントサイズとは独立しています。",
+ themePaletteCustom: "カスタム",
+ customUiReset: "デフォルトに戻す",
+ customUiEditingLight: "編集中:ライト配色",
+ customUiEditingDark: "編集中:ダーク配色",
+ customUiBackground: "背景",
+ customUiForeground: "前景テキスト",
+ customUiPrimary: "プライマリカラー",
+ customUiBorder: "枠線",
+ customUiSidebar: "サイドバー",
},
driverStore: {
jreDirRemoveFailed: "古い JRE ディレクトリを削除できませんでした: {path}(元のエラー: {error})",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index f160346615..e59167ba85 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -6042,6 +6042,15 @@ export default withEnglishFallback({
sidebarIndentDescription: "O deslocamento em pixels da indentação para cada nível da árvore.",
sidebarFontSize: "Tamanho da fonte da barra lateral",
sidebarFontSizeDescription: "Tamanho da fonte (em pixels) da árvore de objetos da barra lateral, independente do tamanho da fonte do editor.",
+ themePaletteCustom: "Personalizado",
+ customUiReset: "Restaurar padrão",
+ customUiEditingLight: "Editando: paleta de cores clara",
+ customUiEditingDark: "Editando: paleta de cores escura",
+ customUiBackground: "Fundo",
+ customUiForeground: "Texto do primeiro plano",
+ customUiPrimary: "Cor primária",
+ customUiBorder: "Borda",
+ customUiSidebar: "Barra lateral",
},
driverStore: {
jreDirRemoveFailed: "Não foi possível remover o diretório JRE antigo: {path} (erro original: {error})",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 048ab04fbb..dad7f16db4 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -5717,6 +5717,15 @@ export default withEnglishFallback({
themePaletteJetbrains: "JetBrains",
themePaletteCursor: "Cursor",
themePaletteClaude: "Claude Code",
+ themePaletteCustom: "自定义",
+ customUiReset: "恢复默认",
+ customUiEditingLight: "正在编辑:浅色配色",
+ customUiEditingDark: "正在编辑:深色配色",
+ customUiBackground: "背景",
+ customUiForeground: "前景文字",
+ customUiPrimary: "主色",
+ customUiBorder: "边框",
+ customUiSidebar: "侧边栏",
followAppTheme: "跟随应用主题",
appLayout: "界面布局",
appLayoutSeparated: "现代分隔",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index e67811e95f..5011e779c5 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -5380,6 +5380,15 @@ export default withEnglishFallback({
sidebarIndentDescription: "每個樹層級縮排的像素位移量。",
sidebarFontSize: "側邊欄字型大小",
sidebarFontSizeDescription: "側邊欄物件樹的字型大小(像素),獨立於編輯器字型大小。",
+ themePaletteCustom: "自訂",
+ customUiReset: "恢復預設",
+ customUiEditingLight: "正在編輯:淺色配色",
+ customUiEditingDark: "正在編輯:深色配色",
+ customUiBackground: "背景",
+ customUiForeground: "前景文字",
+ customUiPrimary: "主色",
+ customUiBorder: "邊框",
+ customUiSidebar: "側邊欄",
},
driverStore: {
jreDirRemoveFailed: "無法刪除舊的 JRE 目錄:{path}(原始錯誤:{error})",
diff --git a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts
index 731336511e..64986dc5e3 100644
--- a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts
+++ b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { buildSqlCompletionThemeRules, resolveCustomThemeBackgrounds, resolveEditorTheme } from "@/lib/editor/editorThemes";
-import type { AppThemePalette } from "@/lib/app/appTheme";
+import { buildSqlCompletionThemeRules, editorDiagnosticColors, editorThemeAppearanceFor, resolveCustomThemeBackgrounds, resolveEditorTheme } from "@/lib/editor/editorThemes";
+import { DEFAULT_APP_CUSTOM_UI_COLORS, wcagContrastRatio, type AppThemePalette } from "@/lib/app/appTheme";
import type { EditorTheme } from "@/stores/settingsStore";
describe("resolveEditorTheme", () => {
@@ -69,6 +69,83 @@ describe("custom editor theme backgrounds", () => {
gutterBackground: "#181825",
});
});
+
+ it("routes the custom UI palette to verified dark/light editor themes by appearance", () => {
+ expect(resolveEditorTheme("app", "dark", "custom")).toBe("one-dark");
+ expect(resolveEditorTheme("app", "light", "custom")).toBe("vscode-light");
+ expect(resolveEditorTheme("one-dark", "light", "custom")).toBe("one-dark");
+ });
+
+ it("derives the follow-app editor appearance from the custom background at light/dark extremes", () => {
+ const darkBg = { ...DEFAULT_APP_CUSTOM_UI_COLORS, background: "#000000" };
+ const lightBg = { ...DEFAULT_APP_CUSTOM_UI_COLORS, background: "#ffffff" };
+ expect(editorThemeAppearanceFor("light", "custom", darkBg)).toBe("dark");
+ expect(editorThemeAppearanceFor("dark", "custom", lightBg)).toBe("light");
+ // Setting off or fixed palettes keep the previous mode-based behavior unchanged.
+ expect(editorThemeAppearanceFor("dark", "pearl")).toBe("dark");
+ expect(editorThemeAppearanceFor("light", "cobalt")).toBe("light");
+ expect(editorThemeAppearanceFor("light", "custom", undefined)).toBe("light");
+ });
+
+ it("keeps every major semantic token readable on the editor themes the custom palette routes to", () => {
+ // Token colors are the shipped values of the curated themes (one-dark from
+ // @codemirror/theme-one-dark, vscode-light from @uiw/codemirror-theme-vscode).
+ // The floors are WCAG AA large-text (3.0) for dimmed comments and a 4.0
+ // readable floor for body tokens; one-dark's variable sits at ~4.4 by design.
+ const curated: Array<{ bg: string; fg: string; comment: string; tokens: Record }> = [
+ {
+ bg: "#282c34",
+ fg: "#abb2bf",
+ comment: "#7d8799",
+ tokens: {
+ keyword: "#c678dd",
+ string: "#98c379",
+ number: "#d19a66",
+ function: "#61afef",
+ type: "#e5c07b",
+ variable: "#e06c75",
+ operator: "#56b6c2",
+ },
+ },
+ {
+ bg: "#ffffff",
+ fg: "#383a42",
+ comment: "#008000",
+ tokens: {
+ keyword: "#383a42",
+ string: "#a31515",
+ number: "#383a42",
+ function: "#383a42",
+ type: "#383a42",
+ variable: "#383a42",
+ operator: "#383a42",
+ },
+ },
+ ];
+
+ for (const theme of curated) {
+ for (const [name, color] of Object.entries(theme.tokens)) {
+ expect(wcagContrastRatio(color, theme.bg), `${name} (${color}) on ${theme.bg}`).toBeGreaterThanOrEqual(4.0);
+ }
+ expect(wcagContrastRatio(theme.comment, theme.bg), `comment on ${theme.bg}`).toBeGreaterThanOrEqual(3.0);
+ }
+ });
+
+ it("uses light diagnostic markers on dark editors and dark markers on light editors", () => {
+ const dark = editorDiagnosticColors("dark");
+ const light = editorDiagnosticColors("light");
+ expect(wcagContrastRatio(dark.error, "#282c34")).toBeGreaterThanOrEqual(3.0);
+ expect(wcagContrastRatio(dark.warning, "#282c34")).toBeGreaterThanOrEqual(3.0);
+ expect(wcagContrastRatio(light.error, "#ffffff")).toBeGreaterThanOrEqual(3.0);
+ expect(wcagContrastRatio(light.warning, "#ffffff")).toBeGreaterThanOrEqual(3.0);
+ });
+
+ it("keeps selected text readable against the curated themes' selection backgrounds", () => {
+ // Selection backgrounds are part of the same verified themes; selected text
+ // (the theme foreground) must stay legible inside the selection highlight.
+ expect(wcagContrastRatio("#abb2bf", "#3E4451")).toBeGreaterThanOrEqual(3.0); // one-dark selection
+ expect(wcagContrastRatio("#383a42", "#add6ff")).toBeGreaterThanOrEqual(3.0); // vscode-light selection
+ });
});
describe("SQL completion theme", () => {
diff --git a/apps/desktop/src/lib/app/__tests__/appTheme.spec.ts b/apps/desktop/src/lib/app/__tests__/appTheme.spec.ts
new file mode 100644
index 0000000000..873a516660
--- /dev/null
+++ b/apps/desktop/src/lib/app/__tests__/appTheme.spec.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import { APP_CUSTOM_UI_DERIVED_VAR_NAMES, DEFAULT_APP_CUSTOM_UI_COLORS, DEFAULT_APP_CUSTOM_UI_COLORS_DARK, appCustomUiColorValue, customUiAppearance, deriveCustomUiColors, hexRelativeLuminance, mixHex, normalizeAppCustomUiColors, readableTextOn, wcagContrastRatio } from "@/lib/app/appTheme";
+
+describe("appTheme custom UI colors", () => {
+ it("falls back to defaults for missing or invalid values and keeps valid hex", () => {
+ expect(normalizeAppCustomUiColors(null)).toEqual(DEFAULT_APP_CUSTOM_UI_COLORS);
+ expect(normalizeAppCustomUiColors({ background: "red", primary: "#2b63b7" }).background).toBe(DEFAULT_APP_CUSTOM_UI_COLORS.background);
+ expect(normalizeAppCustomUiColors({ background: "red", primary: "#2b63b7" }).primary).toBe("#2b63b7");
+ expect(normalizeAppCustomUiColors({ background: "#fff" }).background).toBe(DEFAULT_APP_CUSTOM_UI_COLORS.background);
+ });
+
+ it("converts hex to space-separated rgb and comma-separated triplet", () => {
+ expect(appCustomUiColorValue("#2b63b7")).toEqual({ color: "rgb(43 99 183)", rgbTriplet: "43, 99, 183" });
+ });
+
+ it("computes WCAG luminance and contrast ratio", () => {
+ expect(hexRelativeLuminance("#000000")).toBeCloseTo(0, 5);
+ expect(hexRelativeLuminance("#ffffff")).toBeCloseTo(1, 5);
+ expect(wcagContrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5);
+ expect(wcagContrastRatio("#000000", "#000000")).toBeCloseTo(1, 5);
+ });
+
+ it("ships distinct dark defaults and normalizes them like the light set", () => {
+ expect(DEFAULT_APP_CUSTOM_UI_COLORS_DARK.background).toBe("#131416");
+ expect(DEFAULT_APP_CUSTOM_UI_COLORS_DARK.background).not.toBe(DEFAULT_APP_CUSTOM_UI_COLORS.background);
+ expect(normalizeAppCustomUiColors(DEFAULT_APP_CUSTOM_UI_COLORS_DARK)).toEqual(DEFAULT_APP_CUSTOM_UI_COLORS_DARK);
+ });
+
+ it("derives light/dark appearance from the custom background luminance at both extremes", () => {
+ expect(customUiAppearance({ ...DEFAULT_APP_CUSTOM_UI_COLORS, background: "#000000" })).toBe("dark");
+ expect(customUiAppearance({ ...DEFAULT_APP_CUSTOM_UI_COLORS, background: "#ffffff" })).toBe("light");
+ expect(customUiAppearance({ ...DEFAULT_APP_CUSTOM_UI_COLORS, background: "#123456" })).toBe("dark");
+ expect(customUiAppearance({ ...DEFAULT_APP_CUSTOM_UI_COLORS, background: "#e8f0fa" })).toBe("light");
+ });
+
+ it("mixes two hex colors toward the second by weight", () => {
+ expect(mixHex("#000000", "#ffffff", 0.5)).toBe("rgb(128 128 128)");
+ expect(mixHex("#ffffff", "#000000", 1)).toBe("rgb(0 0 0)");
+ expect(mixHex("#ffffff", "#000000", 0)).toBe("rgb(255 255 255)");
+ });
+
+ it("derives every surface with its paired foreground and never uses color-mix", () => {
+ const derived = deriveCustomUiColors({ ...DEFAULT_APP_CUSTOM_UI_COLORS, primary: "#2b63b7" });
+ expect(Object.keys(derived).sort()).toEqual([...APP_CUSTOM_UI_DERIVED_VAR_NAMES].sort());
+ // Mid-blue primary takes white text for contrast.
+ expect(derived["--primary-foreground"]).toBe("rgb(255 255 255)");
+ expect(derived["--sidebar-primary"]).toBe("rgb(43 99 183)");
+ expect(derived["--muted-foreground"]).toBe("rgb(152 152 152)");
+ for (const value of Object.values(derived)) {
+ expect(value).not.toContain("color-mix");
+ expect(value).not.toContain("NaN");
+ }
+ });
+
+ it("keeps paired foreground/background combinations readable across primary extremes", () => {
+ for (const primary of ["#111111", "#eeeeee", "#808080", "#2b63b7", "#c0392b"]) {
+ const derived = deriveCustomUiColors({ ...DEFAULT_APP_CUSTOM_UI_COLORS, primary });
+ expect(wcagContrastRatio(derived["--primary-foreground"], derived["--sidebar-primary"]), `primary ${primary}`).toBeGreaterThanOrEqual(3.5);
+ }
+ expect(readableTextOn("#000000")).toBe("#ffffff");
+ expect(readableTextOn("#ffffff")).toBe("#0a0a0a");
+ });
+});
diff --git a/apps/desktop/src/lib/app/appTheme.ts b/apps/desktop/src/lib/app/appTheme.ts
index 2ebc5d97ed..772523f069 100644
--- a/apps/desktop/src/lib/app/appTheme.ts
+++ b/apps/desktop/src/lib/app/appTheme.ts
@@ -2,13 +2,155 @@ import type { Theme } from "@tauri-apps/api/window";
export const APP_THEME_STORAGE_KEY = "dbx-theme";
export const APP_THEME_PALETTE_STORAGE_KEY = "dbx-theme-palette";
+export const APP_CUSTOM_UI_STORAGE_KEY = "dbx-theme-custom-ui";
+export const APP_CUSTOM_UI_DARK_STORAGE_KEY = "dbx-theme-custom-ui-dark";
export const APP_CORNER_STYLE_STORAGE_KEY = "dbx-corner-style";
export type AppThemeMode = "light" | "dark" | "system";
export type AppThemeAppearance = "light" | "dark";
-export type AppThemePalette = "pearl" | "mist" | "graphite" | "cobalt" | "sage" | "amber" | "blush" | "vscode" | "idea" | "xcode" | "jetbrains" | "cursor" | "claude";
+export type AppThemePalette = "pearl" | "mist" | "graphite" | "cobalt" | "sage" | "amber" | "blush" | "vscode" | "idea" | "xcode" | "jetbrains" | "cursor" | "claude" | "custom";
export type AppCornerStyle = "none" | "small" | "large";
+export interface AppCustomUiColors {
+ background: string;
+ foreground: string;
+ primary: string;
+ border: string;
+ sidebar: string;
+}
+
+export const DEFAULT_APP_CUSTOM_UI_COLORS: AppCustomUiColors = {
+ background: "#ffffff",
+ foreground: "#0a0a0a",
+ primary: "#171717",
+ border: "#e5e5e5",
+ sidebar: "#fafafa",
+};
+
+export const DEFAULT_APP_CUSTOM_UI_COLORS_DARK: AppCustomUiColors = {
+ background: "#131416",
+ foreground: "#d7d7db",
+ primary: "#d0d0d6",
+ border: "#6e6e72",
+ sidebar: "#19191c",
+};
+
+export interface AppCustomUiColorDef {
+ key: keyof AppCustomUiColors;
+ varName: string;
+ rgbVarName?: string;
+ labelKey: string;
+}
+
+export const APP_CUSTOM_UI_COLOR_DEFS: readonly AppCustomUiColorDef[] = [
+ { key: "background", varName: "--background", labelKey: "settings.customUiBackground" },
+ { key: "foreground", varName: "--foreground", labelKey: "settings.customUiForeground" },
+ { key: "primary", varName: "--primary", rgbVarName: "--dbx-primary-rgb", labelKey: "settings.customUiPrimary" },
+ { key: "border", varName: "--border", labelKey: "settings.customUiBorder" },
+ { key: "sidebar", varName: "--sidebar", labelKey: "settings.customUiSidebar" },
+];
+
+// Every surface and its paired foreground is derived from the chosen colors as
+// concrete rgb values (no color-mix dependency, so legacy WebViews work). The
+// pairings are the readability contract: text colors are computed for the
+// surface they sit on (primary/sidebar-primary foregrounds pick black or white
+// by WCAG contrast).
+export const APP_CUSTOM_UI_DERIVED_VAR_NAMES: readonly string[] = [
+ "--card",
+ "--card-foreground",
+ "--popover",
+ "--popover-foreground",
+ "--secondary",
+ "--secondary-foreground",
+ "--muted",
+ "--muted-foreground",
+ "--accent",
+ "--accent-foreground",
+ "--primary-foreground",
+ "--sidebar-foreground",
+ "--sidebar-accent",
+ "--sidebar-accent-foreground",
+ "--sidebar-primary",
+ "--sidebar-primary-foreground",
+ "--sidebar-border",
+ "--sidebar-ring",
+ "--input",
+ "--ring",
+ "--dbx-chrome",
+ "--dbx-chrome-muted",
+ "--dbx-content",
+ "--dbx-editor-toolbar",
+ "--dbx-gutter",
+ "--dbx-sidebar-header",
+];
+
+export function mixHex(a: string, b: string, weight: number): string {
+ const pa = parseInt(a.slice(1), 16);
+ const pb = parseInt(b.slice(1), 16);
+ const ar = (pa >> 16) & 255;
+ const ag = (pa >> 8) & 255;
+ const ab = pa & 255;
+ const br = (pb >> 16) & 255;
+ const bg2 = (pb >> 8) & 255;
+ const bb = pb & 255;
+ const r = Math.round(ar + (br - ar) * weight);
+ const g = Math.round(ag + (bg2 - ag) * weight);
+ const bl = Math.round(ab + (bb - ab) * weight);
+ return `rgb(${r} ${g} ${bl})`;
+}
+
+/** Pick white or near-black text for the best contrast on the given background. */
+export function readableTextOn(hex: string): string {
+ return wcagContrastRatio(hex, "#ffffff") >= wcagContrastRatio(hex, "#0a0a0a") ? "#ffffff" : "#0a0a0a";
+}
+
+function rgbValue(hex: string): string {
+ return appCustomUiColorValue(hex).color;
+}
+
+export function deriveCustomUiColors(colors: AppCustomUiColors): Record {
+ const { background, foreground, primary, border, sidebar } = colors;
+ const card = mixHex(background, foreground, 0.03);
+ const secondary = mixHex(background, foreground, 0.06);
+ const accent = mixHex(background, foreground, 0.12);
+ const primaryFg = readableTextOn(primary);
+ // Chrome surfaces are near-background tints so foreground text stays readable
+ // even when the user pairs an inverted (dark bg + light fg) custom palette.
+ const chrome = mixHex(background, foreground, 0.02);
+ const chromeMuted = mixHex(background, foreground, 0.035);
+ const toolbar = mixHex(background, foreground, 0.02);
+ const gutter = mixHex(background, foreground, 0.035);
+ const sidebarHeader = mixHex(sidebar, foreground, 0.02);
+ return {
+ "--card": card,
+ "--card-foreground": rgbValue(foreground),
+ "--popover": card,
+ "--popover-foreground": rgbValue(foreground),
+ "--secondary": secondary,
+ "--secondary-foreground": rgbValue(foreground),
+ "--muted": secondary,
+ "--muted-foreground": mixHex(foreground, background, 0.58),
+ "--accent": accent,
+ "--accent-foreground": rgbValue(foreground),
+ "--primary-foreground": rgbValue(primaryFg),
+ "--sidebar-foreground": rgbValue(foreground),
+ "--sidebar-accent": mixHex(sidebar, foreground, 0.06),
+ "--sidebar-accent-foreground": rgbValue(foreground),
+ "--sidebar-primary": rgbValue(primary),
+ "--sidebar-primary-foreground": rgbValue(primaryFg),
+ "--sidebar-border": rgbValue(border),
+ "--sidebar-ring": rgbValue(primary),
+ "--input": rgbValue(border),
+ "--ring": rgbValue(primary),
+ "--dbx-chrome": chrome,
+ "--dbx-chrome-muted": chromeMuted,
+ "--dbx-content": rgbValue(background),
+ "--dbx-editor-toolbar": toolbar,
+ "--dbx-gutter": gutter,
+ "--dbx-sidebar-header": sidebarHeader,
+ };
+}
+
export type AppThemePaletteOption = {
value: AppThemePalette;
labelKey: string;
@@ -30,6 +172,7 @@ export const APP_THEME_PALETTES: AppThemePaletteOption[] = [
{ value: "jetbrains", labelKey: "settings.themePaletteJetbrains", className: "theme-jetbrains", previewColor: "#7b61ff" },
{ value: "cursor", labelKey: "settings.themePaletteCursor", className: "theme-cursor", previewColor: "#6ba4ff" },
{ value: "claude", labelKey: "settings.themePaletteClaude", className: "theme-claude", previewColor: "#c47a50" },
+ { value: "custom", labelKey: "settings.themePaletteCustom", className: null, previewColor: "#171717" },
];
export const APP_THEME_PALETTE_CLASS_NAMES = APP_THEME_PALETTES.map((palette) => palette.className).filter((className): className is string => Boolean(className));
@@ -43,10 +186,79 @@ export function normalizeAppThemeMode(value: string | null): AppThemeMode {
}
export function normalizeAppThemePalette(value: string | null): AppThemePalette {
- if (value === "mist" || value === "graphite" || value === "cobalt" || value === "sage" || value === "amber" || value === "blush" || value === "vscode" || value === "idea" || value === "xcode" || value === "jetbrains" || value === "cursor" || value === "claude" || value === "pearl") return value;
+ if (
+ value === "mist" ||
+ value === "graphite" ||
+ value === "cobalt" ||
+ value === "sage" ||
+ value === "amber" ||
+ value === "blush" ||
+ value === "vscode" ||
+ value === "idea" ||
+ value === "xcode" ||
+ value === "jetbrains" ||
+ value === "cursor" ||
+ value === "claude" ||
+ value === "custom" ||
+ value === "pearl"
+ )
+ return value;
return "pearl";
}
+export function normalizeAppCustomUiColors(value: unknown): AppCustomUiColors {
+ const source = (value && typeof value === "object" ? value : {}) as Record;
+ const out = {} as AppCustomUiColors;
+ for (const key of Object.keys(DEFAULT_APP_CUSTOM_UI_COLORS) as (keyof AppCustomUiColors)[]) {
+ const candidate = source[key];
+ out[key] = typeof candidate === "string" && /^#[0-9a-fA-F]{6}$/.test(candidate) ? candidate : DEFAULT_APP_CUSTOM_UI_COLORS[key];
+ }
+ return out;
+}
+
+export function appCustomUiColorValue(hex: string): { color: string; rgbTriplet: string } {
+ const value = hex.replace("#", "");
+ const r = parseInt(value.slice(0, 2), 16);
+ const g = parseInt(value.slice(2, 4), 16);
+ const b = parseInt(value.slice(4, 6), 16);
+ return { color: `rgb(${r} ${g} ${b})`, rgbTriplet: `${r}, ${g}, ${b}` };
+}
+
+function rgbChannels(value: string): [number, number, number] {
+ const trimmed = value.trim();
+ const rgbMatch = /^rgb\((\d+)\s+(\d+)\s+(\d+)\)$/.exec(trimmed);
+ if (rgbMatch) return [Number(rgbMatch[1]), Number(rgbMatch[2]), Number(rgbMatch[3])];
+ const hex = trimmed.replace("#", "");
+ return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
+}
+
+/** WCAG 2.x relative luminance of a color, 0 (black) .. 1 (white). Accepts #rrggbb or rgb(r g b). */
+export function hexRelativeLuminance(color: string): number {
+ const [r8, g8, b8] = rgbChannels(color);
+ const toLinear = (channel: number): number => {
+ const c = channel / 255;
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * toLinear(r8) + 0.7152 * toLinear(g8) + 0.0722 * toLinear(b8);
+}
+
+/** WCAG 2.x contrast ratio between two colors, 1 .. 21. Accepts #rrggbb or rgb(r g b). */
+export function wcagContrastRatio(a: string, b: string): number {
+ const lighter = Math.max(hexRelativeLuminance(a), hexRelativeLuminance(b));
+ const darker = Math.min(hexRelativeLuminance(a), hexRelativeLuminance(b));
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+/**
+ * Decides the effective light/dark appearance for a custom UI palette from the
+ * chosen background's luminance, so surfaces driven by the app palette (such as
+ * the "Follow app theme" editor) stay readable no matter how dark or light the
+ * background is.
+ */
+export function customUiAppearance(colors: AppCustomUiColors): AppThemeAppearance {
+ return hexRelativeLuminance(colors.background) < 0.2 ? "dark" : "light";
+}
+
export function normalizeAppCornerStyle(value: string | null): AppCornerStyle {
if (value === "none" || value === "small") return value;
return "large";
diff --git a/apps/desktop/src/lib/editor/editorThemes.ts b/apps/desktop/src/lib/editor/editorThemes.ts
index 1bf68ef369..92c9773d10 100644
--- a/apps/desktop/src/lib/editor/editorThemes.ts
+++ b/apps/desktop/src/lib/editor/editorThemes.ts
@@ -1,6 +1,6 @@
import type { Extension } from "@codemirror/state";
import type { EditorTheme, CustomThemeColors } from "@/stores/settingsStore";
-import type { AppThemeAppearance, AppThemePalette } from "@/lib/app/appTheme";
+import { customUiAppearance, type AppCustomUiColors, type AppThemeAppearance, type AppThemePalette } from "@/lib/app/appTheme";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
@@ -658,6 +658,12 @@ export function resolveEditorTheme(theme: EditorTheme, appAppearance: AppThemeAp
return appAppearance === "dark" ? "cursor-dark" : "cursor-light";
case "claude":
return appAppearance === "dark" ? "claude-dark" : "claude-light";
+ case "custom":
+ // The custom UI palette routes the follow-app editor to verified,
+ // self-consistent themes. The caller derives appAppearance from the
+ // actual custom background luminance, so dark backgrounds pick the
+ // dark theme and light backgrounds the light theme.
+ return appAppearance === "dark" ? "one-dark" : "vscode-light";
default:
return appAppearance === "dark" ? "one-dark" : "vscode-light";
}
@@ -665,6 +671,27 @@ export function resolveEditorTheme(theme: EditorTheme, appAppearance: AppThemeAp
return theme;
}
+/**
+ * Effective appearance for the "Follow app theme" editor: with the custom UI
+ * palette the mode's light/dark flag is replaced by the custom background's
+ * luminance, keeping tokens, selection, cursor, search matches and diagnostics
+ * readable on arbitrarily dark/light custom backgrounds. Other palettes keep
+ * the previous appearance unchanged.
+ */
+export function editorThemeAppearanceFor(appAppearance: AppThemeAppearance, appPalette: AppThemePalette, customUiColors?: AppCustomUiColors): AppThemeAppearance {
+ if (appPalette === "custom" && customUiColors) return customUiAppearance(customUiColors);
+ return appAppearance;
+}
+
+/**
+ * Diagnostic marker colors for the editor surface, chosen from the resolved
+ * editor appearance so error/warning underlines stay legible on the actual
+ * editor background instead of using app-level warning/destructive tokens.
+ */
+export function editorDiagnosticColors(appearance: AppThemeAppearance): { error: string; warning: string } {
+ return appearance === "dark" ? { error: "#f87171", warning: "#fbbf24" } : { error: "#dc2626", warning: "#b45309" };
+}
+
/** Load a CodeMirror theme extension by theme name. */
export async function loadEditorTheme(theme: EditorTheme, appAppearance: AppThemeAppearance = "dark", customColors?: CustomThemeColors, appPalette: AppThemePalette = "pearl"): Promise {
const resolvedTheme = resolveEditorTheme(theme, appAppearance, appPalette);