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:
+106
-2
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user