feat(settings): add appearance and custom theme support

Introduce an appearance preference with modern, classic, and custom
modes and a persisted custom theme palette. Add helpers to load,
persist, and apply appearance and custom colors, and wire them into the
app settings lifecycle. Implement a comprehensive light theme and
appearance presets using CSS variables so custom palettes are applied
consistently across the UI, and include appearance in analytics when
settings are saved.

- Persist and apply appearance and custom color palette to :root
- Add muted light theme plus classic/modern presets and custom mapping
- Include appearance and customTheme in settings save and analytics payload
This commit is contained in:
2026-08-23 23:14:07 +02:00
parent 3477070ec3
commit 58027f5e22
4 changed files with 756 additions and 4 deletions
+106 -2
View File
@@ -146,10 +146,12 @@
AiReviewResult,
AiCommitPlan,
AiSettings,
AppAppearance,
AppLanguage,
AppTheme,
AnalyticsSettings,
CommitAiPhase,
CustomThemeColors,
ConflictFile,
DetectedExternalTool,
ExplorerNode,
@@ -253,6 +255,8 @@
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
const APP_THEME_KEY = "gitlite.theme.v1";
const APP_APPEARANCE_KEY = "gitlite.appearance.v1";
const CUSTOM_THEME_KEY = "gitlite.customTheme.v1";
const APP_LANGUAGE_KEY = "gitlite.language.v1";
const EXTERNAL_TOOLS_SETTINGS_KEY = "gitlite.externalTools.v1";
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
@@ -364,6 +368,8 @@
let analyticsNoticeOpen = false;
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
let appTheme: AppTheme = loadThemePreference();
let appAppearance: AppAppearance = loadAppearancePreference();
let customTheme: CustomThemeColors = loadCustomTheme();
let appLanguage: AppLanguage = loadLanguagePreference();
let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings();
let externalToolsConfigured = hasStoredExternalToolsSettings();
@@ -555,6 +561,7 @@
$: fileManagerToolName = externalToolDisplayName("fileManager", externalToolsSettings.fileManager, detectedExternalTools);
$: applyThemePreference(appTheme);
$: applyAppearancePreference(appAppearance, customTheme);
$: applyLanguagePreference(appLanguage);
// ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -1119,6 +1126,98 @@
}
}
function defaultCustomTheme(): CustomThemeColors {
return {
background: "#cfd5dc",
surface: "#e2e6ea",
accent: "#2eb5d1",
text: "#222328",
};
}
function isHexColor(value: unknown): value is string {
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value);
}
function loadAppearancePreference(): AppAppearance {
try {
const stored = localStorage.getItem(APP_APPEARANCE_KEY);
if (stored === "modern" || stored === "classic" || stored === "custom") return stored;
} catch {
// Local storage is optional; the current design remains the default.
}
return "modern";
}
function loadCustomTheme(): CustomThemeColors {
const fallback = defaultCustomTheme();
try {
const stored = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) ?? "null") as Partial<CustomThemeColors> | null;
if (!stored) return fallback;
return {
background: isHexColor(stored.background) ? stored.background : fallback.background,
surface: isHexColor(stored.surface) ? stored.surface : fallback.surface,
accent: isHexColor(stored.accent) ? stored.accent : fallback.accent,
text: isHexColor(stored.text) ? stored.text : fallback.text,
};
} catch {
return fallback;
}
}
function persistAppearancePreference(next: AppAppearance, colors: CustomThemeColors) {
try {
localStorage.setItem(APP_APPEARANCE_KEY, next);
localStorage.setItem(CUSTOM_THEME_KEY, JSON.stringify(colors));
} catch {
// Ignore storage quota/private-mode errors.
}
}
function applyAppearancePreference(next: AppAppearance, colors: CustomThemeColors) {
const root = document.documentElement;
root.dataset.appearance = next;
const customProperties = [
"--app-bg", "--app-button-bg", "--app-input-bg", "--app-dialog-bg", "--app-dialog-chrome",
"--app-settings-row-bg", "--color-surface", "--color-surface-alt", "--color-surface-dim",
"--color-surface-hover", "--color-surface-raised", "--color-surface-solid", "--color-border",
"--color-border-subtle", "--color-border-input", "--color-primary", "--color-primary-dark",
"--color-accent", "--color-ink", "--color-ink-muted", "--color-ink-faint", "--color-ink-dim",
"--color-bar-text", "--color-bar-muted",
];
customProperties.forEach((property) => root.style.removeProperty(property));
if (next !== "custom") return;
const { background, surface, accent, text } = colors;
const values: Record<string, string> = {
"--app-bg": background,
"--app-button-bg": surface,
"--app-input-bg": `color-mix(in srgb, ${surface} 88%, white)`,
"--app-dialog-bg": surface,
"--app-dialog-chrome": `color-mix(in srgb, ${surface} 90%, ${background})`,
"--app-settings-row-bg": `color-mix(in srgb, ${surface} 92%, ${background})`,
"--color-surface": surface,
"--color-surface-alt": `color-mix(in srgb, ${surface} 82%, ${background})`,
"--color-surface-dim": `color-mix(in srgb, ${surface} 88%, ${background})`,
"--color-surface-hover": `color-mix(in srgb, ${surface} 88%, ${text})`,
"--color-surface-raised": `color-mix(in srgb, ${surface} 92%, white)`,
"--color-surface-solid": surface,
"--color-border": `color-mix(in srgb, ${text} 32%, ${surface})`,
"--color-border-subtle": `color-mix(in srgb, ${text} 18%, ${surface})`,
"--color-border-input": `color-mix(in srgb, ${text} 38%, ${surface})`,
"--color-primary": accent,
"--color-primary-dark": `color-mix(in srgb, ${accent} 82%, black)`,
"--color-accent": accent,
"--color-ink": text,
"--color-ink-muted": `color-mix(in srgb, ${text} 76%, ${surface})`,
"--color-ink-faint": `color-mix(in srgb, ${text} 58%, ${surface})`,
"--color-ink-dim": `color-mix(in srgb, ${text} 68%, ${surface})`,
"--color-bar-text": text,
"--color-bar-muted": `color-mix(in srgb, ${text} 62%, ${surface})`,
};
Object.entries(values).forEach(([property, value]) => root.style.setProperty(property, value));
}
function loadLanguagePreference(): AppLanguage {
try {
const stored = localStorage.getItem(APP_LANGUAGE_KEY);
@@ -1157,15 +1256,18 @@
if (appTheme === "system") applyThemePreference(appTheme);
}
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings) {
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextAppearance: AppAppearance, nextCustomTheme: CustomThemeColors, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings) {
const autoRefreshWasEnabled = autoRefreshEnabled;
analyticsSettings = next;
appTheme = nextTheme;
appAppearance = nextAppearance;
customTheme = nextCustomTheme;
appLanguage = nextLanguage;
autoRefreshEnabled = nextAutoRefresh;
externalToolsSettings = nextExternalTools;
persistAnalyticsSettings(next);
persistThemePreference(nextTheme);
persistAppearancePreference(nextAppearance, nextCustomTheme);
persistLanguagePreference(nextLanguage);
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
persistExternalToolsSettings(nextExternalTools);
@@ -1173,7 +1275,7 @@
setTelemetryEnabled(next.enabled);
appSettingsOpen = false;
if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage, auto_refresh: nextAutoRefresh ? 1 : 0 });
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, appearance: nextAppearance, language: nextLanguage, auto_refresh: nextAutoRefresh ? 1 : 0 });
}
function updateCommitMessage(message: string) {
@@ -5599,6 +5701,8 @@
<AppSettingsDialog
analytics={analyticsSettings}
theme={appTheme}
appearance={appAppearance}
customTheme={customTheme}
language={appLanguage}
autoRefresh={autoRefreshEnabled}
externalTools={externalToolsSettings}