mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
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:
co-authored by
Claude Opus 4.8
parent
8da9959293
commit
45793bf76c
@@ -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
@@ -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
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {} } });
|
||||
};
|
||||
Reference in New Issue
Block a user