fix: persist theme selection by name with redesigned theme drawer (#12890)

Theme selection across light and dark modes is now persisted by name
instead of numeric ID. A shared registry (constants/themes.ts) owns the
single source of truth for all predefined theme definitions, and
utils/themeManager.ts provides a central applyCurrentTheme() /
applyThemeForMode() API that replaces scattered inline resolution logic
in App.vue and PredefinedThemes.vue. Custom themes continue to store
their hex color as the source of truth, marked with the
CUSTOM_THEME_NAME sentinel.

The theme drawer is redesigned: a segmented Light/Dark toggle replaces
the tabs, each theme is a single clickable row (no per-row Apply
button), the applied theme is highlighted with a primary ring plus an
Applied badge, long names truncate instead of wrapping, and the custom
color row opens the picker with an Apply/Cancel dialog.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bhargav
2026-06-26 12:12:32 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8da9959293
commit 45793bf76c
13 changed files with 2279 additions and 915 deletions
@@ -27,9 +27,12 @@ export class ThemePage {
this.customColorPreviewLight = page.locator('[data-test="predefined-themes-custom-color-preview-light"]');
// ODialog hosting OColor picker
this.colorPickerDialog = page.locator('[data-test="predefined-themes-color-picker-dialog"]');
// Custom-color picker is an ODialog whose only footer button is the
// primary "Close" button (see PredefinedThemes.vue:215).
this.colorPickerClose = page.locator('[data-test="predefined-themes-color-picker-dialog"] [data-test="o-dialog-primary-btn"]');
// PredefinedThemes.vue's "Pick Custom Color" ODialog now has two footer
// buttons: the primary "Apply" button (confirms + applies the color) and
// the neutral "Cancel" button (closes without applying). ODialog forwards
// them as o-dialog-primary-btn / o-dialog-neutral-btn under the parent slug.
this.colorPickerApply = page.locator('[data-test="predefined-themes-color-picker-dialog"] [data-test="o-dialog-primary-btn"]');
this.colorPickerClose = page.locator('[data-test="predefined-themes-color-picker-dialog"] [data-test="o-dialog-neutral-btn"]');
// General Settings - Theme Management
this.settingsMenuItem = page.locator('[data-test="menu-link-\\/settings-item"]');
@@ -56,12 +59,16 @@ export class ThemePage {
/**
* Returns the theme card locator for a given theme name and mode.
* Cards expose `data-test="predefined-themes-card-{mode}-{slug}"` (added in
* PredefinedThemes.vue) so we no longer depend on text-based filters.
* The PredefinedThemes.vue redesign merged each predefined theme's card and
* Apply control into a single clickable `<button class="theme-row">` whose
* data-test is `predefined-themes-apply-btn-{mode}-{slug}` — there is no
* separate `predefined-themes-card-*` element for predefined themes anymore
* (only the Custom Color row keeps a `-card-` slug; see getCustomColorCard).
* So the row button IS the card.
*/
getThemeCard(themeName, mode = 'light') {
const slug = themeName.toLowerCase().replace(/\s+/g, '-');
return this.page.locator(`[data-test="predefined-themes-card-${mode}-${slug}"]`);
return this.page.locator(`[data-test="predefined-themes-apply-btn-${mode}-${slug}"]`);
}
getApplyButton(themeName, mode = 'light') {
@@ -79,9 +86,14 @@ export class ThemePage {
return this.page.locator(`[data-test="predefined-themes-card-${mode}-custom-color"]`);
}
/** Apply button on the custom-color card for the given mode. */
getCustomColorApplyButton(mode = 'light') {
return this.page.locator(`[data-test="predefined-themes-apply-btn-${mode}-custom-color"]`);
/**
* The custom-color row no longer carries its own Apply button — clicking the
* card opens the "Pick Custom Color" dialog, and the dialog's primary "Apply"
* button is what applies the color. Expose that dialog button here so callers
* have a single source of truth for "apply the custom color".
*/
getCustomColorApplyButton() {
return this.colorPickerApply;
}
// ==================== Navigation ====================
@@ -155,14 +167,18 @@ export class ThemePage {
async selectLightModeTab() {
await this.lightModeTab.click();
// Wait for tab to become active
await expect(this.lightModeTab).toHaveAttribute('data-state', 'active', { timeout: 3000 });
// Wait for tab to become active. These tabs are an OToggleGroup (Reka UI),
// whose selected item exposes aria-pressed="true" / data-state="on" — not
// data-state="active" (that's Reka Tabs, a different primitive).
await expect(this.lightModeTab).toHaveAttribute('aria-pressed', 'true', { timeout: 3000 });
}
async selectDarkModeTab() {
await this.darkModeTab.click();
// Wait for tab to become active
await expect(this.darkModeTab).toHaveAttribute('data-state', 'active', { timeout: 3000 });
// Wait for tab to become active. These tabs are an OToggleGroup (Reka UI),
// whose selected item exposes aria-pressed="true" / data-state="on" — not
// data-state="active" (that's Reka Tabs, a different primitive).
await expect(this.darkModeTab).toHaveAttribute('aria-pressed', 'true', { timeout: 3000 });
}
/**
@@ -236,10 +252,17 @@ export class ThemePage {
await this.selectDarkModeTab();
}
// Apply the custom-color theme via its dedicated apply button
const applyBtn = this.getCustomColorApplyButton(mode);
await expect(applyBtn).toBeVisible({ timeout: 5000 });
await applyBtn.click();
// The custom-color row no longer applies directly — clicking it opens the
// "Pick Custom Color" dialog, whose primary "Apply" button applies the
// color and shows a success toast.
const card = this.getCustomColorCard(mode);
await expect(card).toBeVisible({ timeout: 5000 });
await card.click();
await expect(this.colorPickerDialog).toBeVisible({ timeout: 10000 });
await expect(this.colorPickerApply).toBeVisible({ timeout: 5000 });
await this.colorPickerApply.click();
// Wait for success toast confirming the custom color was applied
await expect(this.successToast.first()).toBeVisible({ timeout: 5000 });
@@ -101,7 +101,7 @@ test.describe("Theme Management Tests", () => {
testLogger.info('Switched back to Light Mode tab');
// Verify predefined theme options (Light tab is currently active)
const expectedThemes = ['Ocean Breeze', 'Purple Dream', 'Indigo Night', 'Sky Blue'];
const expectedThemes = ['O2 Signature', 'O2 Pulse', 'O2 Horizon', 'O2 Beacon'];
for (const themeName of expectedThemes) {
await pm.themePage.expectThemeCardVisible(themeName, 'light');
testLogger.info(`Theme "${themeName}" is visible`);
@@ -128,22 +128,22 @@ test.describe("Theme Management Tests", () => {
// Open predefined themes dialog
await pm.themePage.openPredefinedThemesDialog();
// Apply "Ocean Breeze" theme
await pm.themePage.applyThemeByName('Ocean Breeze');
testLogger.info('Applied Ocean Breeze theme');
// Apply "O2 Pulse" theme
await pm.themePage.applyThemeByName('O2 Pulse');
testLogger.info('Applied O2 Pulse theme');
// Verify notification
await pm.themePage.expectNotificationContains('applied');
testLogger.info('Theme application notification shown');
// Verify "Applied" badge appears
const isApplied = await pm.themePage.isThemeApplied('Ocean Breeze');
const isApplied = await pm.themePage.isThemeApplied('O2 Pulse');
expect(isApplied).toBe(true);
testLogger.info('Applied badge shown for Ocean Breeze');
testLogger.info('Applied badge shown for O2 Pulse');
// Now test reset - Apply a different theme first
await pm.themePage.applyThemeByName('Purple Dream');
testLogger.info('Applied Purple Dream theme');
await pm.themePage.applyThemeByName('O2 Horizon');
testLogger.info('Applied O2 Horizon theme');
// Reset to default
await pm.themePage.resetToDefaultTheme();
@@ -178,9 +178,9 @@ test.describe("Theme Management Tests", () => {
// Select dark mode tab
await pm.themePage.selectDarkModeTab();
// Apply "Sky Blue" theme for dark mode
await pm.themePage.applyThemeByName('Sky Blue', 'dark');
testLogger.info('Applied Sky Blue theme in dark mode');
// Apply "O2 Beacon" theme for dark mode
await pm.themePage.applyThemeByName('O2 Beacon', 'dark');
testLogger.info('Applied O2 Beacon theme in dark mode');
// Verify notification
await pm.themePage.expectNotificationContains('applied');
+14 -108
View File
@@ -27,7 +27,7 @@ import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { onMounted, watch } from "vue";
import config from "@/aws-exports";
import { applyThemeColors, type SemanticColors } from "@/utils/theme";
import { applyCurrentTheme } from "@/utils/themeManager";
import OToastProvider from "@/lib/feedback/Toast/OToastProvider.vue";
import ConfirmDialogProvider from "@/components/ConfirmDialogProvider.vue";
@@ -44,36 +44,16 @@ export default {
// Initialize theme colors on app mount
// This runs once when the app loads and applies the correct theme colors
// based on priority: tempThemeColors > localStorage > org settings > defaults
// The theme is already applied synchronously before mount by bootstrapTheme()
// in main.ts (which also runs the legacy id→name migration). Re-applying here
// is a no-op safety net once the store is available.
onMounted(() => {
// One-time migration: clear saved colors that were old defaults so the
// current default (#3F7994 light / #79a1b4 dark) takes effect automatically.
const THEME_MIGRATION_KEY = 'themeMigrationV3';
if (!localStorage.getItem(THEME_MIGRATION_KEY)) {
const OLD_LIGHT_DEFAULTS = ['#1a8a7a', '#6B76E3', '#5B9FBE'];
const OLD_DARK_DEFAULTS = ['#3ab9aa', '#818CF8', '#5B9FBE'];
const savedLight = localStorage.getItem('customLightColor');
const savedDark = localStorage.getItem('customDarkColor');
if (savedLight && OLD_LIGHT_DEFAULTS.map(c => c.toLowerCase()).includes(savedLight.toLowerCase())) {
localStorage.removeItem('customLightColor');
localStorage.removeItem('appliedLightTheme');
}
if (savedDark && OLD_DARK_DEFAULTS.map(c => c.toLowerCase()).includes(savedDark.toLowerCase())) {
localStorage.removeItem('customDarkColor');
localStorage.removeItem('appliedDarkTheme');
}
localStorage.setItem(THEME_MIGRATION_KEY, '1');
}
initializeThemeColors();
});
// Watch for theme mode changes (light ↔ dark toggle)
// When user toggles between light and dark mode, reapply the correct colors
// NOTE: Always call initializeThemeColors() here regardless of temp colors.
// initializeThemeColors() uses per-mode priority logic:
// tempThemeColors[currentMode] > localStorage > org settings > defaults
// So if the user previewed dark mode colors but switches to light mode,
// initializeThemeColors() will correctly apply the light mode colors (not the dark preview).
// Skipping it would leave stale CSS variables and body classes from the preview.
// Watch for theme mode changes (light ↔ dark toggle) and reapply colors.
// Resolution uses per-mode priority (preview > selected name > custom > default),
// so switching modes always applies the correct colors for that mode.
watch(
() => store.state.theme,
() => {
@@ -81,90 +61,16 @@ export default {
}
);
// Watch for organization settings changes (loaded asynchronously from backend)
// This handles the race condition where App.vue mounts before MainLayout loads org settings
watch(
() => store.state.organizationData?.organizationSettings,
(newSettings) => {
// Only reinitialize if settings actually changed (not just initial undefined -> object)
// Check if theme colors were loaded from backend
if (newSettings?.light_mode_theme_color || newSettings?.dark_mode_theme_color) {
const hasTempColors = store.state.tempThemeColors?.light || store.state.tempThemeColors?.dark;
if (!hasTempColors) {
initializeThemeColors();
}
}
},
{ deep: true }
);
/**
* Initialize and apply theme colors based on priority order
* Priority (highest to lowest):
* 1. Vuex store tempThemeColors (live preview from General Settings)
* 2. localStorage customColor (saved by user from PredefinedThemes or General Settings)
* 3. Organization settings (backend default for the org)
* 4. Application defaults (from Vuex store - centralized)
* Resolve and apply theme colors for the current mode. Priority is handled
* centrally by the theme manager / registry:
* 1. Vuex tempThemeColors (live preview from General Settings)
* 2. Selected predefined theme — resolved by NAME from the registry
* 3. Selected custom theme — the persisted hex color
* 4. Default theme (O2 Signature) — resolved by NAME from the registry
*/
const initializeThemeColors = () => {
// Get default colors from Vuex store (centralized)
const DEFAULT_LIGHT_COLOR = store.state.defaultThemeColors.light;
const DEFAULT_DARK_COLOR = store.state.defaultThemeColors.dark;
const currentMode = store.state.theme === "dark" ? "dark" : "light";
// Light mode color priority
const storeLight = store.state.tempThemeColors?.light; // 1. Temp preview (highest priority)
const localLight = localStorage.getItem('customLightColor'); // 2. Saved custom color
const orgLight = store.state?.organizationData?.organizationSettings?.light_mode_theme_color; // 3. Org default
const customLightColor = storeLight || localLight || orgLight || DEFAULT_LIGHT_COLOR; // 4. App default
// Dark mode color priority
const storeDark = store.state.tempThemeColors?.dark; // 1. Temp preview (highest priority)
const localDark = localStorage.getItem('customDarkColor'); // 2. Saved custom color
const orgDark = store.state?.organizationData?.organizationSettings?.dark_mode_theme_color; // 3. Org default
const customDarkColor = storeDark || localDark || orgDark || DEFAULT_DARK_COLOR; // 4. App default
// Check if user has explicitly applied a theme from PredefinedThemes dialog
// appliedTheme stores the theme ID (-1 for custom, or predefined theme ID)
const appliedLightTheme = localStorage.getItem('appliedLightTheme');
const appliedDarkTheme = localStorage.getItem('appliedDarkTheme');
const appliedTheme = currentMode === 'light' ? appliedLightTheme : appliedDarkTheme;
// Check if there's a temporary preview color (from General Settings color picker)
const hasTempPreview = currentMode === "light"
? !!store.state.tempThemeColors?.light
: !!store.state.tempThemeColors?.dark;
// Check if user has saved a custom color in localStorage (from PredefinedThemes or General Settings)
const hasSavedColor = currentMode === 'light'
? !!localStorage.getItem('customLightColor')
: !!localStorage.getItem('customDarkColor');
// Load persisted semantic colors for the current mode
const semanticColorsRaw = currentMode === 'light'
? localStorage.getItem('lightSemanticColors')
: localStorage.getItem('darkSemanticColors');
const semanticColors: SemanticColors | undefined = semanticColorsRaw
? JSON.parse(semanticColorsRaw)
: undefined;
if (hasTempPreview || hasSavedColor) {
// User has either a temp preview or saved custom color - apply it
const color = currentMode === 'light' ? customLightColor : customDarkColor;
const isDefaultColor = (currentMode === 'light' && color === DEFAULT_LIGHT_COLOR) ||
(currentMode === 'dark' && color === DEFAULT_DARK_COLOR);
applyThemeColors(color, currentMode, isDefaultColor, semanticColors);
} else {
// No theme explicitly selected, apply available colors
const color = currentMode === 'light' ? customLightColor : customDarkColor;
const isDefault = !hasTempPreview && (
color === (currentMode === 'light' ? DEFAULT_LIGHT_COLOR : DEFAULT_DARK_COLOR)
);
applyThemeColors(color, currentMode, isDefault, semanticColors);
}
applyCurrentTheme(store);
};
return {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -875,11 +875,13 @@ export default defineComponent({
color: DEFAULT_DARK_COLOR,
});
// Apply the theme immediately for current mode
// Apply the theme immediately for current mode.
// isDefault=false so the default theme's (O2 Signature) actual colors are
// applied rather than reverting to the base stylesheet palette.
const currentMode = store.state.theme === "dark" ? "dark" : "light";
const color =
currentMode === "light" ? DEFAULT_LIGHT_COLOR : DEFAULT_DARK_COLOR;
applyThemeColors(color, currentMode, true);
applyThemeColors(color, currentMode, false);
// Show notification
toast({
@@ -907,13 +909,9 @@ export default defineComponent({
const color =
mode === "light" ? customLightColor.value : customDarkColor.value;
// Check if the color is a default color
const isDefault =
(mode === "light" && color === DEFAULT_LIGHT_COLOR) ||
(mode === "dark" && color === DEFAULT_DARK_COLOR);
// Apply the theme color for the new mode
applyThemeColors(color, mode, isDefault);
// Apply the theme color for the new mode. isDefault=false so the resolved
// color (including the O2 Signature default) is always applied directly.
applyThemeColors(color, mode, false);
};
const cancelLogoText = () => {
+212
View File
@@ -0,0 +1,212 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach } from "vitest";
import {
PREDEFINED_THEMES,
CUSTOM_THEME_NAME,
DEFAULT_THEME_NAME,
THEME_STORAGE_KEYS,
getThemeByName,
getThemeById,
getDefaultTheme,
resolveThemeForMode,
migrateLegacyThemeStorage,
} from "./themes";
// A small in-memory Storage stand-in for migration tests.
const createStorage = (initial: Record<string, string> = {}): Storage => {
const map = new Map<string, string>(Object.entries(initial));
return {
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
setItem: (k: string, v: string) => void map.set(k, String(v)),
removeItem: (k: string) => void map.delete(k),
clear: () => map.clear(),
key: (i: number) => Array.from(map.keys())[i] ?? null,
get length() {
return map.size;
},
} as Storage;
};
describe("theme registry", () => {
describe("lookups", () => {
it("finds a theme by name", () => {
expect(getThemeByName("O2 Pulse")?.id).toBe(2);
});
it("returns undefined for an unknown name", () => {
expect(getThemeByName("Does Not Exist")).toBeUndefined();
});
it("returns undefined for null/empty name", () => {
expect(getThemeByName(null)).toBeUndefined();
expect(getThemeByName("")).toBeUndefined();
});
it("finds a theme by legacy id", () => {
expect(getThemeById(10)?.name).toBe("O2 Signature");
});
it("returns undefined for an unknown id", () => {
expect(getThemeById(9999)).toBeUndefined();
});
it("resolves the default theme to O2 Signature", () => {
expect(DEFAULT_THEME_NAME).toBe("O2 Signature");
expect(getDefaultTheme().name).toBe("O2 Signature");
});
});
describe("resolveThemeForMode priority", () => {
it("prefers the live preview color above everything", () => {
const r = resolveThemeForMode({
mode: "light",
tempColor: "#ABCDEF",
appliedThemeName: "O2 Pulse",
customColor: "#111111",
});
expect(r.source).toBe("preview");
expect(r.themeColor).toBe("#ABCDEF");
});
it("resolves a predefined theme by NAME from the current registry color", () => {
const ocean = getThemeByName("O2 Pulse")!;
const r = resolveThemeForMode({
mode: "light",
appliedThemeName: "O2 Pulse",
// a stale cached color must be ignored in favour of the registry color
customColor: "#000000",
});
expect(r.source).toBe("predefined");
expect(r.themeColor).toBe(ocean.light.themeColor);
});
it("carries semantic colors for predefined themes that define them", () => {
const crimson = getThemeByName("O2 Crimson Ink")!;
const r = resolveThemeForMode({
mode: "dark",
appliedThemeName: "O2 Crimson Ink",
});
expect(r.semanticColors).toEqual(crimson.dark.semanticColors);
});
it("falls back to default when the selected name no longer exists", () => {
const r = resolveThemeForMode({
mode: "light",
appliedThemeName: "Removed In v2",
});
expect(r.source).toBe("default");
expect(r.themeColor).toBe(getDefaultTheme().light.themeColor);
});
it("uses the stored hex color for a custom theme", () => {
const r = resolveThemeForMode({
mode: "light",
appliedThemeName: CUSTOM_THEME_NAME,
customColor: "#FF0000",
});
expect(r.source).toBe("custom");
expect(r.themeColor).toBe("#FF0000");
});
it("ignores any org color and uses the named default when nothing is selected", () => {
const def = getDefaultTheme();
const r = resolveThemeForMode({
mode: "dark",
appliedThemeName: null,
});
expect(r.source).toBe("default");
expect(r.themeColor).toBe(def.dark.themeColor);
});
it("falls back to the default theme (O2 Signature) when nothing else is set", () => {
const def = getDefaultTheme();
const r = resolveThemeForMode({ mode: "dark", appliedThemeName: null });
expect(r.source).toBe("default");
expect(r.themeColor).toBe(def.dark.themeColor);
});
it("never marks the result as the base default (always applies real colors)", () => {
const r = resolveThemeForMode({ mode: "light", appliedThemeName: null });
expect(r.isDefault).toBe(false);
});
});
describe("migrateLegacyThemeStorage", () => {
let storage: Storage;
beforeEach(() => {
storage = createStorage();
});
it("converts a numeric legacy id to the theme name", () => {
storage.setItem("appliedLightTheme", "2");
migrateLegacyThemeStorage(storage);
expect(storage.getItem(THEME_STORAGE_KEYS.light.appliedName)).toBe(
"O2 Pulse",
);
expect(storage.getItem("appliedLightTheme")).toBeNull();
});
it("converts the legacy custom marker (-1) to CUSTOM_THEME_NAME", () => {
storage.setItem("appliedDarkTheme", "-1");
migrateLegacyThemeStorage(storage);
expect(storage.getItem(THEME_STORAGE_KEYS.dark.appliedName)).toBe(
CUSTOM_THEME_NAME,
);
expect(storage.getItem("appliedDarkTheme")).toBeNull();
});
it("drops an unknown legacy id (falls back to default at resolve time)", () => {
storage.setItem("appliedLightTheme", "9999");
migrateLegacyThemeStorage(storage);
expect(storage.getItem(THEME_STORAGE_KEYS.light.appliedName)).toBeNull();
expect(storage.getItem("appliedLightTheme")).toBeNull();
});
it("does not overwrite an already-migrated name key", () => {
storage.setItem("appliedLightTheme", "2");
storage.setItem(THEME_STORAGE_KEYS.light.appliedName, "O2 Lens");
migrateLegacyThemeStorage(storage);
expect(storage.getItem(THEME_STORAGE_KEYS.light.appliedName)).toBe(
"O2 Lens",
);
});
it("is a no-op when there is no legacy key", () => {
migrateLegacyThemeStorage(storage);
expect(storage.getItem(THEME_STORAGE_KEYS.light.appliedName)).toBeNull();
expect(storage.getItem(THEME_STORAGE_KEYS.dark.appliedName)).toBeNull();
});
});
describe("registry integrity", () => {
it("has unique ids and names", () => {
const ids = PREDEFINED_THEMES.map((t) => t.id);
const names = PREDEFINED_THEMES.map((t) => t.name);
expect(new Set(ids).size).toBe(ids.length);
expect(new Set(names).size).toBe(names.length);
});
it("includes the default theme name in the registry", () => {
expect(names()).toContain(DEFAULT_THEME_NAME);
});
});
});
function names() {
return PREDEFINED_THEMES.map((t) => t.name);
}
+309
View File
@@ -0,0 +1,309 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import type { SemanticColors } from "@/utils/theme";
/**
* Theme registry — single source of truth for predefined themes.
*
* Theme SELECTION is persisted by NAME (not by color), so when a theme's
* color codes change in a future release, every user who selected that theme
* by name automatically picks up the new colors on next load.
*
* - Predefined theme → persisted by `name` (e.g. "O2 Signature").
* - Custom theme → persisted by `CUSTOM_THEME_NAME` + the chosen hex color.
* - Default/fallback → resolves to `DEFAULT_THEME_NAME` ("O2 Signature") by name,
* so editing O2 Signature's colors also changes the default.
*/
export interface ThemeModeColors {
// Hex color used for buttons/toggles/borders and to derive the primary palette
themeColor: string;
// Opacity on a 1-10 scale (10 = fully opaque). Kept for backwards-compat.
themeColorOpacity?: number;
// Optional multi-color semantic palette (errors/success/secondary button, etc.)
semanticColors?: SemanticColors;
}
export interface PredefinedTheme {
// Stable numeric id — kept only for legacy localStorage migration.
id: number;
// Stable display name — this is the persisted key for theme selection.
name: string;
light: ThemeModeColors;
dark: ThemeModeColors;
}
/** Marker stored in `appliedXThemeName` when the user picked a custom color. */
export const CUSTOM_THEME_NAME = "__custom__";
/** The theme used as the default/fallback when nothing else is selected. */
export const DEFAULT_THEME_NAME = "O2 Signature";
/** localStorage keys, grouped per mode. */
export const THEME_STORAGE_KEYS = {
light: {
appliedName: "appliedLightThemeName",
color: "customLightColor",
semantic: "lightSemanticColors",
legacyApplied: "appliedLightTheme",
},
dark: {
appliedName: "appliedDarkThemeName",
color: "customDarkColor",
semantic: "darkSemanticColors",
legacyApplied: "appliedDarkTheme",
},
} as const;
/**
* Predefined themes with both light and dark mode colors.
* Update a theme's color codes here and every user who selected it by name
* (or who is on the default) will pick up the change on next load.
*/
export const PREDEFINED_THEMES: PredefinedTheme[] = [
// Theme names are intentionally color-agnostic (observability/product terms),
// so a theme's accent colors can change in any release without the name going stale.
{
id: 10,
name: "O2 Signature",
light: {
themeColor: "#575FC5",
themeColorOpacity: 10,
},
dark: {
themeColor: "#6069D3",
themeColorOpacity: 10,
},
},
{
id: 2,
name: "O2 Pulse",
light: {
themeColor: "#3F7994",
themeColorOpacity: 10,
},
dark: {
themeColor: "#3F7994",
themeColorOpacity: 10,
},
},
{
id: 4,
name: "O2 Horizon",
light: {
themeColor: "#077A7F",
themeColorOpacity: 10,
},
dark: {
themeColor: "#588CF3",
themeColorOpacity: 10,
},
},
{
id: 5,
name: "O2 Beacon",
light: {
themeColor: "#3369D6",
themeColorOpacity: 10,
},
dark: {
themeColor: "#6EA8FE",
themeColorOpacity: 10,
},
},
{
id: 8,
name: "O2 Lens",
light: {
themeColor: "#4682FA",
themeColorOpacity: 10,
},
dark: {
themeColor: "#E56D17",
themeColorOpacity: 10,
},
},
{
id: 14,
name: "O2 Crimson Ink",
light: {
themeColor: "#E11D48",
themeColorOpacity: 10,
semanticColors: {
error: "#F97316",
errorBg: "#FFF7ED",
errorText: "#C2410C",
success: "#6366F1",
successBg: "#EEF2FF",
successText: "#3730A3",
secondaryBtnBg: "#FFE4E6",
secondaryBtnText: "#BE123C",
secondaryBtnBorder: "#FECDD3",
outlineText: "#BE123C",
outlineBorder: "#FECDD3",
ghostText: "#BE123C",
},
},
dark: {
themeColor: "#FB7185",
themeColorOpacity: 10,
semanticColors: {
error: "#FB923C",
errorBg: "#3A1A08",
errorText: "#FB923C",
success: "#818CF8",
successBg: "#1E1B4B",
successText: "#A5B4FC",
secondaryBtnBg: "#3D0617",
secondaryBtnText: "#FECDD3",
secondaryBtnBorder: "#9F1239",
outlineText: "#FECDD3",
outlineBorder: "#9F1239",
ghostText: "#FECDD3",
},
},
},
];
/** Find a predefined theme by its stable name. */
export const getThemeByName = (
name: string | null | undefined,
): PredefinedTheme | undefined =>
name ? PREDEFINED_THEMES.find((t) => t.name === name) : undefined;
/** Find a predefined theme by its legacy numeric id. */
export const getThemeById = (
id: number | null | undefined,
): PredefinedTheme | undefined =>
id == null ? undefined : PREDEFINED_THEMES.find((t) => t.id === id);
/** The default/fallback theme (O2 Signature), with a safe fallback to the first theme. */
export const getDefaultTheme = (): PredefinedTheme =>
getThemeByName(DEFAULT_THEME_NAME) ?? PREDEFINED_THEMES[0];
/** Slugify a theme name into kebab-case for data-test attributes ("O2 Pulse" -> "o2-pulse"). */
export const themeNameSlug = (name: string): string =>
name.toLowerCase().replace(/\s+/g, "-");
export type ThemeSource = "preview" | "predefined" | "custom" | "default";
export interface ResolveThemeInput {
mode: "light" | "dark";
/** Live preview color from Vuex `tempThemeColors[mode]` (highest priority). */
tempColor?: string | null;
/** Persisted selection: a predefined theme name or `CUSTOM_THEME_NAME`. */
appliedThemeName?: string | null;
/** Persisted hex color (source of truth for custom; render-cache for predefined). */
customColor?: string | null;
/** Persisted semantic colors for a custom theme, if any. */
customSemanticColors?: SemanticColors | null;
}
export interface ResolvedTheme {
themeColor: string;
semanticColors?: SemanticColors;
/**
* Always false: we always apply real colors (the default is O2 Signature's
* colors), so the inline primary palette is always synced rather than cleared.
*/
isDefault: boolean;
source: ThemeSource;
}
/**
* Resolve the effective theme colors for a mode, by priority:
* 1. Live preview (tempColor)
* 2. Selected predefined theme — resolved by NAME from the registry
* 3. Selected custom theme — the persisted hex color
* 4. Default theme (O2 Signature) — resolved by NAME from the registry
*
* The named default is authoritative: when no theme is explicitly selected it
* always wins, so changing the default theme's colors in the registry takes
* effect immediately. (Org-level theme colors are not part of this resolution.)
*/
export const resolveThemeForMode = (input: ResolveThemeInput): ResolvedTheme => {
const mode = input.mode;
// 1. Live preview from General Settings color picker
if (input.tempColor) {
return { themeColor: input.tempColor, isDefault: false, source: "preview" };
}
const name = input.appliedThemeName;
// 2. Predefined theme selected by name — always read the CURRENT registry color
if (name && name !== CUSTOM_THEME_NAME) {
const theme = getThemeByName(name);
if (theme) {
const m = theme[mode];
return {
themeColor: m.themeColor,
semanticColors: m.semanticColors,
isDefault: false,
source: "predefined",
};
}
// Unknown name (theme removed in a release) → fall through to default.
}
// 3. Custom theme — use the persisted hex color
if (name === CUSTOM_THEME_NAME && input.customColor) {
return {
themeColor: input.customColor,
semanticColors: input.customSemanticColors ?? undefined,
isDefault: false,
source: "custom",
};
}
// 4. Default theme (O2 Signature), resolved by name
const def = getDefaultTheme();
const m = def[mode];
return {
themeColor: m.themeColor,
semanticColors: m.semanticColors,
isDefault: false,
source: "default",
};
};
/**
* One-time migration of the legacy id-based selection to the name-based model.
* - legacy `appliedLightTheme` / `appliedDarkTheme` held a numeric id, or "-1" for custom.
* - new `appliedLightThemeName` / `appliedDarkThemeName` hold a theme name or CUSTOM_THEME_NAME.
* Safe to call repeatedly; it only acts when a legacy key is present and the new key is absent.
*/
export const migrateLegacyThemeStorage = (
storage: Storage = localStorage,
): void => {
(["light", "dark"] as const).forEach((mode) => {
const keys = THEME_STORAGE_KEYS[mode];
const legacy = storage.getItem(keys.legacyApplied);
if (legacy === null) return;
// Only set the new key if it hasn't been written already.
if (storage.getItem(keys.appliedName) === null) {
if (legacy === "-1") {
storage.setItem(keys.appliedName, CUSTOM_THEME_NAME);
} else {
const theme = getThemeById(parseInt(legacy, 10));
if (theme) storage.setItem(keys.appliedName, theme.name);
}
}
storage.removeItem(keys.legacyApplied);
});
};
+5
View File
@@ -31,6 +31,11 @@ import {
} from "./composables/contextProviders";
import { buildVersionChecker } from "./utils/buildVersionChecker";
import { toast } from "@/lib/feedback/Toast/useToast";
import { bootstrapTheme } from "@/utils/themeManager";
// Apply the resolved theme synchronously before the app mounts so the first
// paint already uses the correct colors (no flash of the base stylesheet theme).
bootstrapTheme();
const app = createApp(App);
const router = createRouter(store);
+7 -6
View File
@@ -676,6 +676,7 @@ import { useI18n } from "vue-i18n";
import { byString } from "../../utils/json";
import { getImageURL, useLocalWrapContent } from "../../utils/zincutils";
import { formatLargeNumber } from "@/utils/formatters";
import { CUSTOM_THEME_NAME, THEME_STORAGE_KEYS } from "@/constants/themes";
import useLogs from "../../composables/useLogs";
import { useSearchStream } from "@/composables/useLogs/useSearchStream";
import usePatterns from "@/composables/useLogs/usePatterns";
@@ -1157,16 +1158,16 @@ export default defineComponent({
// Watch for theme color changes in localStorage
const handleThemeColorChange = () => {
const currentMode = store.state.theme === "dark" ? "dark" : "light";
const appliedThemeKey =
currentMode === "light" ? "appliedLightTheme" : "appliedDarkTheme";
const appliedTheme = localStorage.getItem(appliedThemeKey);
const appliedThemeName = localStorage.getItem(
THEME_STORAGE_KEYS[currentMode].appliedName,
);
// If -1, user is picking custom color - debounce to avoid performance issues
if (appliedTheme === "-1") {
// Custom color: user may be dragging the picker — debounce to avoid jank
if (appliedThemeName === CUSTOM_THEME_NAME) {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => reDrawChart(), 300);
} else {
// Predefined theme applied - re-render immediately
// Predefined / default theme applied - re-render immediately
if (debounceTimer) clearTimeout(debounceTimer);
reDrawChart();
}
+7 -5
View File
@@ -22,6 +22,7 @@ import {
import streams from "./streams";
import logs from "./logs";
import incidents from "./incidents";
import { getDefaultTheme } from "@/constants/themes";
const pos = window.location.pathname.indexOf("/web/");
@@ -110,12 +111,13 @@ export default createStore({
isWebinarBannerVisible: false,
currentChatTimestamp: null,
chatUpdated: false,
// Default theme colors (Default Blue theme)
// These are the application's default colors used as fallback when no custom colors are set
// Centralized here so they can be updated in one place instead of duplicating across components
// Default theme colors — derived from the default theme (O2 Signature) in the
// theme registry so there is a single source of truth. Used as the fallback
// by chart consumers that read colors directly. Changing the default theme's
// colors in `@/constants/themes` automatically updates these.
defaultThemeColors: {
light: "#3F7994", // Blue-teal — matches --color-primary-600
dark: "#79a1b4", // Blue-teal 400 for dark mode (brighter on dark surfaces)
light: getDefaultTheme().light.themeColor,
dark: getDefaultTheme().dark.themeColor,
},
// GitHub dashboard gallery cache
githubDashboardGallery: {
+187
View File
@@ -0,0 +1,187 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock the DOM-applying layer so we can assert on the resolved color/args.
vi.mock("@/utils/theme", () => ({
applyThemeColors: vi.fn(),
}));
import {
applyThemeForMode,
applyCurrentTheme,
bootstrapTheme,
} from "./themeManager";
import { applyThemeColors } from "@/utils/theme";
import {
CUSTOM_THEME_NAME,
getThemeByName,
getDefaultTheme,
THEME_STORAGE_KEYS,
} from "@/constants/themes";
const storeWith = (overrides: any = {}) => ({
state: {
theme: "light",
tempThemeColors: { light: null, dark: null },
...overrides,
},
});
describe("themeManager", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
it("applies the default theme (O2 Signature) when nothing is selected", () => {
const def = getDefaultTheme();
const resolved = applyThemeForMode("light", storeWith());
expect(resolved.source).toBe("default");
expect(applyThemeColors).toHaveBeenCalledWith(
def.light.themeColor,
"light",
false,
def.light.semanticColors,
);
});
it("resolves a predefined theme by name and refreshes the color cache", () => {
const ocean = getThemeByName("O2 Pulse")!;
localStorage.setItem(THEME_STORAGE_KEYS.light.appliedName, "O2 Pulse");
// Stale cached color that must be overwritten from the registry.
localStorage.setItem(THEME_STORAGE_KEYS.light.color, "#000000");
const resolved = applyThemeForMode("light", storeWith());
expect(resolved.source).toBe("predefined");
expect(applyThemeColors).toHaveBeenCalledWith(
ocean.light.themeColor,
"light",
false,
undefined,
);
// Cache refreshed to the current registry color.
expect(localStorage.getItem(THEME_STORAGE_KEYS.light.color)).toBe(
ocean.light.themeColor,
);
});
it("caches semantic colors for a predefined theme that defines them", () => {
const crimson = getThemeByName("O2 Crimson Ink")!;
localStorage.setItem(THEME_STORAGE_KEYS.dark.appliedName, "O2 Crimson Ink");
applyThemeForMode("dark", storeWith({ theme: "dark" }));
expect(localStorage.getItem(THEME_STORAGE_KEYS.dark.semantic)).toBe(
JSON.stringify(crimson.dark.semanticColors),
);
});
it("uses the stored hex for a custom theme and does not rewrite the cache", () => {
localStorage.setItem(
THEME_STORAGE_KEYS.light.appliedName,
CUSTOM_THEME_NAME,
);
localStorage.setItem(THEME_STORAGE_KEYS.light.color, "#FF0000");
const resolved = applyThemeForMode("light", storeWith());
expect(resolved.source).toBe("custom");
expect(applyThemeColors).toHaveBeenCalledWith(
"#FF0000",
"light",
false,
undefined,
);
expect(localStorage.getItem(THEME_STORAGE_KEYS.light.color)).toBe("#FF0000");
});
it("does not persist the transient live preview color", () => {
const resolved = applyThemeForMode(
"light",
storeWith({ tempThemeColors: { light: "#ABCDEF", dark: null } }),
);
expect(resolved.source).toBe("preview");
expect(localStorage.getItem(THEME_STORAGE_KEYS.light.color)).toBeNull();
});
it("applyCurrentTheme uses the store's current mode", () => {
const def = getDefaultTheme();
applyCurrentTheme(storeWith({ theme: "dark" }));
expect(applyThemeColors).toHaveBeenCalledWith(
def.dark.themeColor,
"dark",
false,
def.dark.semanticColors,
);
});
it("refreshes the render-cache for the default theme so charts track it", () => {
const def = getDefaultTheme();
applyThemeForMode("light", storeWith());
// No explicit selection, but the cache now reflects the default color.
expect(localStorage.getItem(THEME_STORAGE_KEYS.light.color)).toBe(
def.light.themeColor,
);
});
describe("bootstrapTheme", () => {
it("applies the default theme synchronously when nothing is stored", () => {
const def = getDefaultTheme();
bootstrapTheme();
expect(applyThemeColors).toHaveBeenCalledWith(
def.light.themeColor,
"light",
false,
def.light.semanticColors,
);
});
it("uses the saved mode and selected predefined theme", () => {
const ocean = getThemeByName("O2 Pulse")!;
localStorage.setItem("theme", "dark");
localStorage.setItem(THEME_STORAGE_KEYS.dark.appliedName, "O2 Pulse");
bootstrapTheme();
expect(applyThemeColors).toHaveBeenCalledWith(
ocean.dark.themeColor,
"dark",
false,
undefined,
);
});
it("migrates a legacy id selection before applying", () => {
localStorage.setItem("appliedLightTheme", "2"); // legacy id for O2 Pulse
const ocean = getThemeByName("O2 Pulse")!;
bootstrapTheme();
expect(localStorage.getItem(THEME_STORAGE_KEYS.light.appliedName)).toBe(
"O2 Pulse",
);
expect(applyThemeColors).toHaveBeenCalledWith(
ocean.light.themeColor,
"light",
false,
undefined,
);
});
});
});
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { applyThemeColors, type SemanticColors } from "@/utils/theme";
import {
resolveThemeForMode,
migrateLegacyThemeStorage,
THEME_STORAGE_KEYS,
type ResolvedTheme,
} from "@/constants/themes";
/**
* Theme manager — the single orchestrator that resolves the effective theme
* (by name for predefined / default, by color for custom) and applies it.
*
* Used by main.ts (synchronous bootstrap before first paint), App.vue (on load
* and theme-mode toggle) and PredefinedThemes.vue, so resolution logic lives in
* exactly one place.
*/
type ThemeMode = "light" | "dark";
// Minimal shape of the parts of the Vuex store this module reads.
interface ThemeStoreLike {
state: {
theme?: string;
tempThemeColors?: { light?: string | null; dark?: string | null };
};
}
const parseSemantic = (raw: string | null): SemanticColors | undefined => {
if (!raw) return undefined;
try {
return JSON.parse(raw) as SemanticColors;
} catch {
return undefined;
}
};
/**
* Resolve and apply the theme for a specific mode, then refresh the localStorage
* render-cache (`customLightColor`/`customDarkColor` + semantic colors) so chart
* consumers that read those keys directly always reflect the active theme.
*
* The cache is rewritten for every persisted source (predefined re-derived by
* name, custom, and the default theme) — so a color change shipped in a release
* propagates to direct localStorage readers too. The transient live preview is
* never persisted.
*/
export const applyThemeForMode = (
mode: ThemeMode,
store: ThemeStoreLike,
): ResolvedTheme => {
const keys = THEME_STORAGE_KEYS[mode];
const resolved = resolveThemeForMode({
mode,
tempColor: store.state.tempThemeColors?.[mode],
appliedThemeName: localStorage.getItem(keys.appliedName),
customColor: localStorage.getItem(keys.color),
customSemanticColors: parseSemantic(localStorage.getItem(keys.semantic)),
});
applyThemeColors(
resolved.themeColor,
mode,
resolved.isDefault,
resolved.semanticColors,
);
// Refresh the render-cache for any persisted source so direct localStorage
// readers (charts) stay in sync. Never persist the transient live preview.
if (resolved.source !== "preview") {
localStorage.setItem(keys.color, resolved.themeColor);
if (resolved.semanticColors) {
localStorage.setItem(keys.semantic, JSON.stringify(resolved.semanticColors));
} else {
localStorage.removeItem(keys.semantic);
}
}
return resolved;
};
/** Resolve and apply the theme for the store's current mode. */
export const applyCurrentTheme = (store: ThemeStoreLike): ResolvedTheme => {
const mode: ThemeMode = store.state.theme === "dark" ? "dark" : "light";
return applyThemeForMode(mode, store);
};
/**
* Synchronous theme bootstrap — call BEFORE the app mounts (in main.ts) so the
* very first paint already uses the resolved theme instead of the base
* stylesheet default. Resolution here depends only on localStorage + the theme
* registry (no Vuex/org/network), so it can run fully synchronously.
*/
export const bootstrapTheme = (): void => {
// Convert any legacy id-based selection to the name-based model first, so the
// resolve below reads the correct `appliedXThemeName` keys.
migrateLegacyThemeStorage();
const mode: ThemeMode =
localStorage.getItem("theme") === "dark" ? "dark" : "light";
// No store yet → no live preview at boot.
applyThemeForMode(mode, { state: { theme: mode, tempThemeColors: {} } });
};