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

@@ -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(() => {
<div class="flex h-9 items-end">
<Label class="whitespace-normal leading-tight">{{ t("settings.colorTheme") }}</Label>
</div>
<Select :model-value="themePalette" @update:model-value="(value) => setThemePalette(value as AppThemePalette)">
<SelectTrigger class="h-8 w-full gap-1">
<SelectValue :placeholder="t('settings.selectColorTheme')">
<span v-if="selectedThemePaletteOption" class="flex min-w-0 items-center gap-1">
<span
class="h-3 w-3 shrink-0 rounded-full border border-border shadow-xs"
:style="{
background: selectedThemePaletteOption.previewColor,
}"
/>
<span class="truncate">{{ selectedThemePaletteOption.label }}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in appThemePaletteOptions" :key="option.value" :value="option.value">
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full border border-border shadow-xs" :style="{ background: option.previewColor }" />
{{ option.label }}
</div>
</SelectItem>
</SelectContent>
</Select>
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Select :model-value="themePalette" @update:model-value="(value) => setThemePalette(value as AppThemePalette)">
<SelectTrigger class="h-8 w-full gap-1">
<SelectValue :placeholder="t('settings.selectColorTheme')">
<span v-if="selectedThemePaletteOption" class="flex min-w-0 items-center gap-1">
<span
class="h-3 w-3 shrink-0 rounded-full border border-border shadow-xs"
:style="{
background: selectedThemePaletteOption.previewColor,
}"
/>
<span class="truncate">{{ selectedThemePaletteOption.label }}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in appThemePaletteOptions" :key="option.value" :value="option.value">
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full border border-border shadow-xs" :style="{ background: option.previewColor }" />
{{ option.label }}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<Button v-if="themePalette === 'custom'" type="button" variant="outline" size="sm" class="h-8 shrink-0" @click="resetCustomUiColors">
{{ t("settings.customUiReset") }}
</Button>
</div>
</div>
<div class="settings-appearance-field min-w-0">
@@ -4208,6 +4219,20 @@ onUnmounted(() => {
</SelectContent>
</Select>
</div>
<template v-if="themePalette === 'custom'">
<div class="settings-appearance-field min-w-0">
<div class="flex h-9 items-center gap-1">
<Label class="truncate text-xs text-muted-foreground">{{ isDark ? t("settings.customUiEditingDark") : t("settings.customUiEditingLight") }}</Label>
</div>
</div>
<div v-for="def in APP_CUSTOM_UI_COLOR_DEFS" :key="def.key" class="settings-appearance-field min-w-0">
<div class="flex h-9 items-center justify-between gap-2">
<Label :for="`custom-ui-${def.key}`" class="truncate text-xs text-muted-foreground">{{ t(def.labelKey) }}</Label>
<input :id="`custom-ui-${def.key}`" type="color" class="h-6 w-10 shrink-0 cursor-pointer rounded border border-border bg-transparent p-0.5" :value="activeCustomUiColors[def.key]" @input="updateCustomUiColor(def.key, ($event.target as HTMLInputElement).value)" />
</div>
</div>
</template>
</div>
<div class="grid gap-3 md:grid-cols-2">
@@ -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) {
+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 };
}
+9
View File
@@ -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",
+9
View File
@@ -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})",
+9
View File
@@ -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})",
+9
View File
@@ -6093,6 +6093,15 @@ export default withEnglishFallback({
sidebarIndentDescription: "各ツリー階層のインデントのピクセルオフセット。",
sidebarFontSize: "サイドバーのフォントサイズ",
sidebarFontSizeDescription: "サイドバーのオブジェクトツリーのフォントサイズ(ピクセル)。エディターのフォントサイズとは独立しています。",
themePaletteCustom: "カスタム",
customUiReset: "デフォルトに戻す",
customUiEditingLight: "編集中:ライト配色",
customUiEditingDark: "編集中:ダーク配色",
customUiBackground: "背景",
customUiForeground: "前景テキスト",
customUiPrimary: "プライマリカラー",
customUiBorder: "枠線",
customUiSidebar: "サイドバー",
},
driverStore: {
jreDirRemoveFailed: "古い JRE ディレクトリを削除できませんでした: {path}(元のエラー: {error})",
+9
View File
@@ -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})",
+9
View File
@@ -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: "现代分隔",
+9
View File
@@ -5380,6 +5380,15 @@ export default withEnglishFallback({
sidebarIndentDescription: "每個樹層級縮排的像素位移量。",
sidebarFontSize: "側邊欄字型大小",
sidebarFontSizeDescription: "側邊欄物件樹的字型大小(像素),獨立於編輯器字型大小。",
themePaletteCustom: "自訂",
customUiReset: "恢復預設",
customUiEditingLight: "正在編輯:淺色配色",
customUiEditingDark: "正在編輯:深色配色",
customUiBackground: "背景",
customUiForeground: "前景文字",
customUiPrimary: "主色",
customUiBorder: "邊框",
customUiSidebar: "側邊欄",
},
driverStore: {
jreDirRemoveFailed: "無法刪除舊的 JRE 目錄:{path}(原始錯誤:{error})",
+79 -2
View File
@@ -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<string, string> }> = [
{
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", () => {
+63
View File
@@ -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");
});
});
+214 -2
View File
@@ -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<string, string> {
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<string, unknown>;
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";
+28 -1
View File
@@ -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<Extension> {
const resolvedTheme = resolveEditorTheme(theme, appAppearance, appPalette);