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);