diff --git a/tests/ui-testing/pages/generalPages/themePage.js b/tests/ui-testing/pages/generalPages/themePage.js
index 04b04d92cd..ba28bda405 100644
--- a/tests/ui-testing/pages/generalPages/themePage.js
+++ b/tests/ui-testing/pages/generalPages/themePage.js
@@ -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 `` 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 });
diff --git a/tests/ui-testing/playwright-tests/GeneralTests/theme-management.spec.js b/tests/ui-testing/playwright-tests/GeneralTests/theme-management.spec.js
index 5cf5337d04..2601f27082 100644
--- a/tests/ui-testing/playwright-tests/GeneralTests/theme-management.spec.js
+++ b/tests/ui-testing/playwright-tests/GeneralTests/theme-management.spec.js
@@ -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');
diff --git a/web/src/App.vue b/web/src/App.vue
index ec5da1f98d..37164397d9 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -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 {
diff --git a/web/src/components/PredefinedThemes.spec.ts b/web/src/components/PredefinedThemes.spec.ts
index 8f494b30c8..b64001da93 100644
--- a/web/src/components/PredefinedThemes.spec.ts
+++ b/web/src/components/PredefinedThemes.spec.ts
@@ -5,7 +5,7 @@
// 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,
+// 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.
@@ -17,7 +17,15 @@ import { mount } from "@vue/test-utils";
import { describe, expect, it, beforeEach, vi } from "vitest";
import PredefinedThemes from "./PredefinedThemes.vue";
import i18n from "@/locales";
-import { nextTick } from "vue";
+import { nextTick, ref } from "vue";
+import {
+ CUSTOM_THEME_NAME,
+ getDefaultTheme,
+ themeNameSlug,
+} from "@/constants/themes";
+
+// The default theme (O2 Signature) provides the fallback colors.
+const DEFAULT_THEME = getDefaultTheme();
// Use vi.hoisted so these variables are available inside vi.mock() factory functions
// (vi.mock calls are hoisted to the top of the file before variable declarations)
@@ -45,12 +53,12 @@ vi.mock("quasar", async () => {
};
});
-// Mock composable
+// Mock composable — use vi.fn() so individual tests can override via mockReturnValueOnce
vi.mock("@/composables/usePredefinedThemes", () => ({
- usePredefinedThemes: () => ({
+ usePredefinedThemes: vi.fn(() => ({
isOpen: { value: true },
toggleThemes: vi.fn(),
- }),
+ })),
}));
// Mock theme utility
@@ -61,6 +69,13 @@ vi.mock("@/utils/theme", () => ({
}),
}));
+// Mock themeManager so onMounted doesn't try to touch real localStorage/DOM
+vi.mock("@/utils/themeManager", () => ({
+ applyCurrentTheme: vi.fn(),
+ applyThemeForMode: vi.fn(),
+ bootstrapTheme: vi.fn(),
+}));
+
// Mock Vuex store
const mockStore = {
state: {
@@ -87,16 +102,16 @@ const mockStore = {
// Mock localStorage
const mockLocalStorage = {
getItem: vi.fn((key: string) => {
- if (key === 'appliedLightTheme') return null;
- if (key === 'appliedDarkTheme') return null;
- if (key === 'customLightColor') return null;
- if (key === 'customDarkColor') return null;
+ if (key === "appliedLightTheme") return null;
+ if (key === "appliedDarkTheme") return null;
+ if (key === "customLightColor") return null;
+ if (key === "customDarkColor") return null;
return null;
}),
setItem: vi.fn(),
removeItem: vi.fn(),
};
-Object.defineProperty(window, 'localStorage', {
+Object.defineProperty(window, "localStorage", {
value: mockLocalStorage,
writable: true,
});
@@ -121,67 +136,85 @@ const createWrapper = (props = {}, options = {}) => {
store: mockStore,
},
stubs: {
- // ODrawer stub (outer container, replaces q-dialog)
+ // ODrawer stub — outer container
ODrawer: {
- name: 'ODrawer',
- template: '{{ title }}
',
- props: ['open', 'size', 'seamless', 'title'],
- emits: ['update:open'],
+ name: "ODrawer",
+ template:
+ '{{ title }}
',
+ props: ["open", "size", "seamless", "title"],
+ emits: ["update:open"],
},
- // ODialog stub (inner color picker dialog, replaces q-dialog)
+ // ODialog stub — inner color picker dialog
ODialog: {
- name: 'ODialog',
- template: '{{ title }} {{ primaryButtonLabel }}
',
- props: ['open', 'size', 'title', 'primaryButtonLabel'],
- emits: ['update:open', 'click:primary', 'click:secondary', 'click:neutral'],
+ name: "ODialog",
+ template:
+ '{{ title }} {{ primaryButtonLabel }} {{ neutralButtonLabel }}
',
+ props: [
+ "open",
+ "size",
+ "title",
+ "primaryButtonLabel",
+ "neutralButtonLabel",
+ ],
+ emits: ["update:open", "click:primary", "click:secondary", "click:neutral"],
},
+ // OToggleGroup — replaces the old OTabs segmented control
+ OToggleGroup: {
+ name: "OToggleGroup",
+ template:
+ '
',
+ props: ["modelValue", "type"],
+ emits: ["update:modelValue"],
+ },
+ // OToggleGroupItem — individual toggle items; forward data-test and value
+ OToggleGroupItem: {
+ name: "OToggleGroupItem",
+ template:
+ ' ',
+ props: ["value", "iconLeft"],
+ emits: ["click"],
+ inheritAttrs: false,
+ },
+ // OBadge — inline badge for "Applied" indicator; forward data-test
+ OBadge: {
+ name: "OBadge",
+ template:
+ ' ',
+ props: ["variant", "size", "icon"],
+ inheritAttrs: false,
+ },
+ OButton: {
+ template:
+ ' ',
+ props: ["variant", "size", "disabled", "loading"],
+ emits: ["click"],
+ inheritAttrs: false,
+ },
+ OCardSection: {
+ template: '
',
+ },
+ OSeparator: {
+ template: ' ',
+ },
+ OIcon: {
+ template: ' ',
+ props: ["name", "size"],
+ },
+ OColor: {
+ template: '
',
+ props: ["modelValue"],
+ emits: ["update:modelValue"],
+ },
+ // Legacy Quasar stubs kept for safety (no longer rendered, harmless)
QCard: {
template: '
',
},
QCardSection: {
template: '
',
},
- QTabs: {
- template: '
',
- props: ['modelValue'],
- },
- QTab: {
- template: ' ',
- },
- QTabPanels: {
- template: '
',
- props: ['modelValue'],
- },
- QTabPanel: {
- template: '
',
- props: ['name'],
- },
QBtn: {
- template: ' ',
- },
- // O2 component stubs (migrated from Quasar)
- OTabs: {
- template: '
',
- props: ['modelValue', 'dense', 'align', 'orientation'],
- emits: ['update:modelValue'],
- },
- OTab: {
- template: ' ',
- props: ['name', 'label'],
- },
- OTabPanels: {
- template: '
',
- props: ['modelValue', 'animated'],
- emits: ['update:modelValue'],
- },
- OTabPanel: {
- template: '
',
- props: ['name'],
- },
- OButton: {
- template: ' ',
- props: ['variant', 'size', 'disabled', 'loading'],
- emits: ['click'],
+ template:
+ ' ',
},
QIcon: {
template: ' ',
@@ -191,8 +224,8 @@ const createWrapper = (props = {}, options = {}) => {
},
QColor: {
template: '
',
- props: ['modelValue'],
- emits: ['update:modelValue'],
+ props: ["modelValue"],
+ emits: ["update:modelValue"],
},
},
...options,
@@ -205,38 +238,66 @@ describe("PredefinedThemes", () => {
vi.clearAllMocks();
mockToast.mockReturnValue(vi.fn());
mockLocalStorage.getItem.mockImplementation((key: string) => {
- if (key === 'appliedLightTheme') return null;
- if (key === 'appliedDarkTheme') return null;
- if (key === 'customLightColor') return null;
- if (key === 'customDarkColor') return null;
+ if (key === "appliedLightTheme") return null;
+ if (key === "appliedDarkTheme") return null;
+ if (key === "customLightColor") return null;
+ if (key === "customDarkColor") return null;
return null;
});
+ // Reset store state mutations between tests
+ mockStore.state.tempThemeColors = { light: null, dark: null };
+ mockStore.state.theme = "light";
+ mockStore.state.organizationData = {
+ organizationSettings: {
+ light_mode_theme_color: "#3F7994",
+ dark_mode_theme_color: "#5B9FBE",
+ },
+ };
});
describe("Component Mounting", () => {
it("should mount successfully", () => {
const wrapper = createWrapper();
+
expect(wrapper.exists()).toBe(true);
});
it("should render drawer (ODrawer)", () => {
const wrapper = createWrapper();
+
expect(wrapper.find('[data-test-stub="o-drawer"]').exists()).toBe(true);
});
it("should render inner color picker ODialog", () => {
const wrapper = createWrapper();
+
expect(wrapper.find('[data-test-stub="o-dialog"]').exists()).toBe(true);
});
- it("should render light and dark mode tabs", () => {
+ it("should render light and dark mode toggle items", () => {
const wrapper = createWrapper();
- expect(wrapper.find('[data-test-stub="q-tabs"]').exists()).toBe(true);
+
+ // New segmented control uses OToggleGroupItem with data-test attrs
+ expect(
+ wrapper.find('[data-test="predefined-themes-tab-light"]').exists(),
+ ).toBe(true);
+ expect(
+ wrapper.find('[data-test="predefined-themes-tab-dark"]').exists(),
+ ).toBe(true);
+ });
+
+ it("should render the OToggleGroup container", () => {
+ const wrapper = createWrapper();
+
+ expect(wrapper.find('[data-test-stub="o-toggle-group"]').exists()).toBe(
+ true,
+ );
});
it("should initialize with light mode tab active", () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
+
expect(vm.activeTab).toBe("light");
});
});
@@ -245,34 +306,42 @@ describe("PredefinedThemes", () => {
it("should display predefined themes", () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
+
expect(vm.predefinedThemes.length).toBeGreaterThan(0);
});
it("includes O2 Crimson Ink as a predefined theme with semanticColors", () => {
const wrapper = createWrapper();
const { vm } = wrapper;
- const o2Theme = (vm as any).predefinedThemes.find((t: any) => t.name === 'O2 Crimson Ink');
+ const o2Theme = (vm as any).predefinedThemes.find(
+ (t: any) => t.name === "O2 Crimson Ink",
+ );
+
expect(o2Theme).toBeDefined();
- expect(o2Theme.light.themeColor).toBe('#E11D48');
+ expect(o2Theme.light.themeColor).toBe("#E11D48");
expect(o2Theme.light.semanticColors).toBeDefined();
- expect(o2Theme.light.semanticColors.error).toBe('#F97316');
- expect(o2Theme.light.semanticColors.success).toBe('#6366F1');
+ expect(o2Theme.light.semanticColors.error).toBe("#F97316");
+ expect(o2Theme.light.semanticColors.success).toBe("#6366F1");
expect(o2Theme.dark.semanticColors).toBeDefined();
});
it("passes semanticColors to applyThemeColors when applying O2 Crimson Ink", async () => {
const wrapper = createWrapper();
const { vm } = wrapper;
- const { applyThemeColors } = await import('@/utils/theme');
- const o2Theme = (vm as any).predefinedThemes.find((t: any) => t.name === 'O2 Crimson Ink');
- (vm as any).applyTheme(o2Theme, 'light');
+ const { applyThemeColors } = await import("@/utils/theme");
+ const o2Theme = (vm as any).predefinedThemes.find(
+ (t: any) => t.name === "O2 Crimson Ink",
+ );
+
+ (vm as any).applyTheme(o2Theme, "light");
+
expect(applyThemeColors).toHaveBeenCalledWith(
- '#E11D48',
- 'light',
+ "#E11D48",
+ "light",
false,
expect.objectContaining({
- error: '#F97316',
- success: '#6366F1',
+ error: "#F97316",
+ success: "#6366F1",
}),
);
});
@@ -280,12 +349,16 @@ describe("PredefinedThemes", () => {
it("does not pass semanticColors when applying a non-signature theme", async () => {
const wrapper = createWrapper();
const { vm } = wrapper;
- const { applyThemeColors } = await import('@/utils/theme');
- const oceanTheme = (vm as any).predefinedThemes.find((t: any) => t.name === 'Ocean Breeze');
- (vm as any).applyTheme(oceanTheme, 'light');
+ const { applyThemeColors } = await import("@/utils/theme");
+ const oceanTheme = (vm as any).predefinedThemes.find(
+ (t: any) => t.name === "O2 Pulse",
+ );
+
+ (vm as any).applyTheme(oceanTheme, "light");
+
expect(applyThemeColors).toHaveBeenCalledWith(
oceanTheme.light.themeColor,
- 'light',
+ "light",
false,
undefined,
);
@@ -293,14 +366,83 @@ describe("PredefinedThemes", () => {
it("should display custom color option", () => {
const wrapper = createWrapper();
+
expect(wrapper.text()).toContain("Custom Color");
});
- it("should show default colors from store", () => {
+ it("should use the default theme (O2 Signature) colors as defaults", () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
- expect(vm.DEFAULT_LIGHT_COLOR).toBe("#3F7994");
- expect(vm.DEFAULT_DARK_COLOR).toBe("#5B9FBE");
+
+ expect(vm.DEFAULT_LIGHT_COLOR).toBe(DEFAULT_THEME.light.themeColor);
+ expect(vm.DEFAULT_DARK_COLOR).toBe(DEFAULT_THEME.dark.themeColor);
+ });
+
+ it("renders theme row buttons for each predefined theme in light mode", () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ // Each predefined theme should have a row button in the active (light) mode
+ const pulseSlug = themeNameSlug("O2 Pulse");
+ const pulseRow = wrapper.find(
+ `[data-test="predefined-themes-apply-btn-light-${pulseSlug}"]`,
+ );
+
+ expect(pulseRow.exists()).toBe(true);
+ });
+
+ it("renders theme row for O2 Crimson Ink in light mode", () => {
+ const wrapper = createWrapper();
+
+ const slug = themeNameSlug("O2 Crimson Ink");
+ const row = wrapper.find(
+ `[data-test="predefined-themes-apply-btn-light-${slug}"]`,
+ );
+
+ expect(row.exists()).toBe(true);
+ });
+
+ it("renders custom color row for active mode", () => {
+ const wrapper = createWrapper();
+
+ expect(
+ wrapper
+ .find('[data-test="predefined-themes-card-light-custom-color"]')
+ .exists(),
+ ).toBe(true);
+ });
+
+ it("shows hint text 'Pick any brand hex' when custom theme is not applied", () => {
+ const wrapper = createWrapper();
+
+ expect(wrapper.text()).toContain("Pick any brand hex");
+ });
+
+ it("shows bottom note about device-only storage", () => {
+ const wrapper = createWrapper();
+
+ expect(wrapper.text()).toContain("Saved to this device only");
+ });
+ });
+
+ describe("Mode (computed)", () => {
+ it("mode is 'light' when activeTab is 'light'", () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.activeTab = "light";
+
+ expect(vm.mode).toBe("light");
+ });
+
+ it("mode is 'dark' when activeTab is 'dark'", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.activeTab = "dark";
+ await nextTick();
+
+ expect(vm.mode).toBe("dark");
});
});
@@ -326,25 +468,160 @@ describe("PredefinedThemes", () => {
expect(vm.activeTab).toBe("light");
});
- });
- describe("Theme Application", () => {
- it("should have apply buttons for each theme", () => {
- const wrapper = createWrapper();
- const applyButtons = wrapper.findAll('[data-test-stub="q-btn"]');
- expect(applyButtons.length).toBeGreaterThan(0);
- });
-
- it("should store theme in localStorage when applied", async () => {
+ it("renders dark-mode theme rows when activeTab switches to dark", async () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
- // Apply a predefined theme
- const theme = vm.predefinedThemes[0];
- vm.applyTheme(theme);
+ vm.activeTab = "dark";
await nextTick();
- expect(mockLocalStorage.setItem).toHaveBeenCalled();
+ const slug = themeNameSlug("O2 Pulse");
+ expect(
+ wrapper
+ .find(`[data-test="predefined-themes-apply-btn-dark-${slug}"]`)
+ .exists(),
+ ).toBe(true);
+ });
+
+ it("renders dark-mode custom-color row when activeTab switches to dark", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.activeTab = "dark";
+ await nextTick();
+
+ expect(
+ wrapper
+ .find('[data-test="predefined-themes-card-dark-custom-color"]')
+ .exists(),
+ ).toBe(true);
+ });
+ });
+
+ describe("Theme Application", () => {
+ it("should have apply buttons (theme rows) for each theme", () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ // Every predefined theme should have a clickable row
+ for (const theme of vm.predefinedThemes) {
+ const slug = themeNameSlug(theme.name);
+ const row = wrapper.find(
+ `[data-test="predefined-themes-apply-btn-light-${slug}"]`,
+ );
+ expect(row.exists()).toBe(true);
+ }
+ });
+
+ it("should store theme in localStorage when applied via applyTheme()", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ const theme = vm.predefinedThemes[0];
+ vm.applyTheme(theme, "light");
+ await nextTick();
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
+ "appliedLightThemeName",
+ theme.name,
+ );
+ });
+
+ it("clicking the O2 Pulse theme row calls localStorage.setItem with its name", async () => {
+ const wrapper = createWrapper();
+
+ const slug = themeNameSlug("O2 Pulse");
+ const row = wrapper.find(
+ `[data-test="predefined-themes-apply-btn-light-${slug}"]`,
+ );
+ expect(row.exists()).toBe(true);
+
+ await row.trigger("click");
+ await nextTick();
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
+ "appliedLightThemeName",
+ "O2 Pulse",
+ );
+ });
+
+ it("clicking a theme row calls applyThemeColors with the correct color", async () => {
+ const wrapper = createWrapper();
+ const { applyThemeColors } = await import("@/utils/theme");
+ const vm = wrapper.vm as any;
+ const pulseTheme = vm.predefinedThemes.find(
+ (t: any) => t.name === "O2 Pulse",
+ );
+
+ const slug = themeNameSlug("O2 Pulse");
+ const row = wrapper.find(
+ `[data-test="predefined-themes-apply-btn-light-${slug}"]`,
+ );
+ await row.trigger("click");
+ await nextTick();
+
+ expect(applyThemeColors).toHaveBeenCalledWith(
+ pulseTheme.light.themeColor,
+ "light",
+ false,
+ undefined,
+ );
+ });
+
+ it("shows applied badge for the theme that is currently applied", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ // Mark O2 Pulse as applied
+ const slug = themeNameSlug("O2 Pulse");
+ vm.appliedLightThemeName = "O2 Pulse";
+ await nextTick();
+
+ const badge = wrapper.find(
+ `[data-test="predefined-themes-applied-badge-light-${slug}"]`,
+ );
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toContain("Applied");
+ });
+
+ it("does NOT show applied badge for a theme that is NOT applied", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ // Only O2 Pulse is applied; O2 Horizon badge should not exist
+ vm.appliedLightThemeName = "O2 Pulse";
+ await nextTick();
+
+ const horizonSlug = themeNameSlug("O2 Horizon");
+ const badge = wrapper.find(
+ `[data-test="predefined-themes-applied-badge-light-${horizonSlug}"]`,
+ );
+ expect(badge.exists()).toBe(false);
+ });
+
+ it("isThemeApplied returns true when the theme name matches the applied name", () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.appliedLightThemeName = "O2 Signature";
+
+ const theme = vm.predefinedThemes.find(
+ (t: any) => t.name === "O2 Signature",
+ );
+ expect(vm.isThemeApplied(theme, "light")).toBe(true);
+ });
+
+ it("isThemeApplied returns false when a different theme is applied", () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.appliedLightThemeName = "O2 Pulse";
+
+ const theme = vm.predefinedThemes.find(
+ (t: any) => t.name === "O2 Signature",
+ );
+ expect(vm.isThemeApplied(theme, "light")).toBe(false);
});
});
@@ -352,16 +629,18 @@ describe("PredefinedThemes", () => {
it("should have custom light color initialized", () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
+
expect(vm.customLightColor).toBeDefined();
});
it("should have custom dark color initialized", () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
+
expect(vm.customDarkColor).toBeDefined();
});
- it("should open color picker for light mode", async () => {
+ it("should open color picker for light mode via openColorPicker()", async () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
@@ -372,7 +651,7 @@ describe("PredefinedThemes", () => {
expect(vm.currentPickerMode).toBe("light");
});
- it("should open color picker for dark mode", async () => {
+ it("should open color picker for dark mode via openColorPicker()", async () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
@@ -383,6 +662,41 @@ describe("PredefinedThemes", () => {
expect(vm.currentPickerMode).toBe("dark");
});
+ it("clicking the custom-color row opens the picker for the active mode", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ const row = wrapper.find(
+ '[data-test="predefined-themes-card-light-custom-color"]',
+ );
+ expect(row.exists()).toBe(true);
+
+ await row.trigger("click");
+ await nextTick();
+
+ expect(vm.showColorPicker).toBe(true);
+ expect(vm.currentPickerMode).toBe("light");
+ });
+
+ it("clicking the custom-color row in dark mode opens picker with dark mode", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.activeTab = "dark";
+ await nextTick();
+
+ const row = wrapper.find(
+ '[data-test="predefined-themes-card-dark-custom-color"]',
+ );
+ expect(row.exists()).toBe(true);
+
+ await row.trigger("click");
+ await nextTick();
+
+ expect(vm.showColorPicker).toBe(true);
+ expect(vm.currentPickerMode).toBe("dark");
+ });
+
it("should update custom color when color picker changes", async () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
@@ -395,7 +709,19 @@ describe("PredefinedThemes", () => {
expect(vm.customLightColor).toBe("#FF0000");
});
- it("should apply custom theme and save to localStorage", async () => {
+ it("updateCustomColor updates dark color when currentPickerMode is dark", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.tempColor = "#0000FF";
+ vm.currentPickerMode = "dark";
+ vm.updateCustomColor();
+ await nextTick();
+
+ expect(vm.customDarkColor).toBe("#0000FF");
+ });
+
+ it("should apply custom theme and save to localStorage via applyCustomTheme()", async () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
@@ -404,25 +730,130 @@ describe("PredefinedThemes", () => {
await nextTick();
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
- 'customLightColor',
- '#FF0000'
+ "customLightColor",
+ "#FF0000",
);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
- 'appliedLightTheme',
- '-1'
+ "appliedLightThemeName",
+ CUSTOM_THEME_NAME,
+ );
+ });
+
+ it("applyCustomTheme stores CUSTOM_THEME_NAME for dark mode", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.customDarkColor = "#AABBCC";
+ vm.applyCustomTheme("dark");
+ await nextTick();
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
+ "appliedDarkThemeName",
+ CUSTOM_THEME_NAME,
);
});
});
- describe("Reset Functionality", () => {
- it("should have reset to default theme function", () => {
+ describe("confirmCustomColor (Dialog Apply button)", () => {
+ it("confirmCustomColor closes the color picker", async () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
- // The component should have resetToDefaultTheme function
- expect(typeof vm.resetToDefaultTheme).toBe('function');
+
+ vm.showColorPicker = true;
+ vm.currentPickerMode = "light";
+ await nextTick();
+
+ vm.confirmCustomColor();
+ await nextTick();
+
+ expect(vm.showColorPicker).toBe(false);
});
- it("should reset to organization settings when available", async () => {
+ it("confirmCustomColor applies the custom theme for the current picker mode", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.customLightColor = "#123456";
+ vm.showColorPicker = true;
+ vm.currentPickerMode = "light";
+ await nextTick();
+
+ vm.confirmCustomColor();
+ await nextTick();
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
+ "appliedLightThemeName",
+ CUSTOM_THEME_NAME,
+ );
+ });
+
+ it("clicking the ODialog primary button closes the color picker via confirmCustomColor", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.showColorPicker = true;
+ vm.currentPickerMode = "light";
+ await nextTick();
+
+ const primaryBtn = wrapper.find('[data-test-stub="o-dialog-primary"]');
+ expect(primaryBtn.exists()).toBe(true);
+ await primaryBtn.trigger("click");
+ await nextTick();
+
+ expect(vm.showColorPicker).toBe(false);
+ });
+
+ it("clicking the ODialog neutral button closes the picker without applying", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vi.clearAllMocks();
+ vm.showColorPicker = true;
+ vm.currentPickerMode = "light";
+ await nextTick();
+
+ const neutralBtn = wrapper.find('[data-test-stub="o-dialog-neutral"]');
+ expect(neutralBtn.exists()).toBe(true);
+ await neutralBtn.trigger("click");
+ await nextTick();
+
+ expect(vm.showColorPicker).toBe(false);
+ // neutral must NOT call applyCustomTheme — no appliedLightThemeName set
+ expect(mockLocalStorage.setItem).not.toHaveBeenCalledWith(
+ "appliedLightThemeName",
+ CUSTOM_THEME_NAME,
+ );
+ });
+
+ it("shows applied badge for custom color row after confirmCustomColor", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.showColorPicker = true;
+ vm.currentPickerMode = "light";
+ await nextTick();
+
+ vm.confirmCustomColor();
+ await nextTick();
+
+ // The badge appears when isCustomThemeApplied returns true
+ const badge = wrapper.find(
+ '[data-test="predefined-themes-applied-badge-light-custom-color"]',
+ );
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toContain("Applied");
+ });
+ });
+
+ describe("Reset Functionality", () => {
+ it("should have resetToDefaultTheme function", () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ expect(typeof vm.resetToDefaultTheme).toBe("function");
+ });
+
+ it("should call localStorage.removeItem when resetting", async () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
@@ -432,16 +863,70 @@ describe("PredefinedThemes", () => {
expect(mockLocalStorage.removeItem).toHaveBeenCalled();
});
- it("should show notification after reset", async () => {
+ it("should show toast notification after reset", async () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
vm.resetToDefaultTheme();
await nextTick();
- // resetToDefaultTheme calls toast() from @/lib/feedback/Toast/useToast, not useQuasar().notify
+ // resetToDefaultTheme calls toast() from @/lib/feedback/Toast/useToast
expect(mockToast).toHaveBeenCalled();
});
+
+ it("reset button is rendered in the drawer header-right slot", () => {
+ const wrapper = createWrapper();
+
+ const resetBtn = wrapper.find(
+ '[data-test="predefined-themes-reset-btn"]',
+ );
+ expect(resetBtn.exists()).toBe(true);
+ });
+
+ it("clicking the reset button triggers the reset (localStorage.removeItem called)", async () => {
+ const wrapper = createWrapper();
+
+ // Arrange — pre-set an applied theme so removeItem is definitely called by reset
+ (wrapper.vm as any).appliedLightThemeName = "O2 Pulse";
+ await nextTick();
+
+ const resetBtn = wrapper.find(
+ '[data-test="predefined-themes-reset-btn"]',
+ );
+ expect(resetBtn.exists()).toBe(true);
+
+ await resetBtn.trigger("click");
+ await nextTick();
+
+ // If resetToDefaultTheme ran, it removes the localStorage entries
+ expect(mockLocalStorage.removeItem).toHaveBeenCalled();
+ });
+
+ it("after reset, appliedLightThemeName is null", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.appliedLightThemeName = "O2 Pulse";
+ await nextTick();
+
+ vm.resetToDefaultTheme();
+ await nextTick();
+
+ expect(vm.appliedLightThemeName).toBeNull();
+ });
+
+ it("after reset, appliedDarkThemeName is null", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.appliedDarkThemeName = "O2 Horizon";
+ await nextTick();
+
+ vm.resetToDefaultTheme();
+ await nextTick();
+
+ expect(vm.appliedDarkThemeName).toBeNull();
+ });
});
describe("Temp Theme Colors (Preview)", () => {
@@ -465,13 +950,13 @@ describe("PredefinedThemes", () => {
it("should show 'Applied' badge when theme is permanently applied", () => {
mockStore.state.tempThemeColors.light = null;
mockLocalStorage.getItem.mockImplementation((key: string) => {
- if (key === 'appliedLightTheme') return '-1';
+ if (key === "appliedLightThemeName") return CUSTOM_THEME_NAME;
return null;
});
const wrapper = createWrapper();
const vm = wrapper.vm as any;
- vm.appliedLightTheme = -1;
+ vm.appliedLightThemeName = CUSTOM_THEME_NAME;
const isApplied = vm.isCustomThemeApplied("light");
expect(isApplied).toBe(true);
@@ -482,7 +967,7 @@ describe("PredefinedThemes", () => {
it("should prioritize temp colors over localStorage", () => {
mockStore.state.tempThemeColors.light = "#FF0000";
mockLocalStorage.getItem.mockImplementation((key: string) => {
- if (key === 'customLightColor') return '#00FF00';
+ if (key === "customLightColor") return "#00FF00";
return null;
});
@@ -495,7 +980,7 @@ describe("PredefinedThemes", () => {
it("should use localStorage when no temp colors", () => {
mockStore.state.tempThemeColors.light = null;
mockLocalStorage.getItem.mockImplementation((key: string) => {
- if (key === 'customLightColor') return '#00FF00';
+ if (key === "customLightColor") return "#00FF00";
return null;
});
@@ -520,6 +1005,7 @@ describe("PredefinedThemes", () => {
it("should handle missing organization settings gracefully", () => {
mockStore.state.organizationData = {};
const wrapper = createWrapper();
+
expect(wrapper.exists()).toBe(true);
});
@@ -527,26 +1013,423 @@ describe("PredefinedThemes", () => {
mockStore.state.tempThemeColors = { light: null, dark: null };
const wrapper = createWrapper();
const vm = wrapper.vm as any;
+
expect(vm.customLightColor).toBeDefined();
});
it("should handle empty predefined themes", () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
+
expect(Array.isArray(vm.predefinedThemes)).toBe(true);
});
+
+ it("isCustomThemeApplied returns false when no theme is applied", () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.appliedLightThemeName = null;
+
+ expect(vm.isCustomThemeApplied("light")).toBe(false);
+ });
+
+ it("isCustomThemeApplied returns false when a predefined (not custom) theme is applied", () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.appliedLightThemeName = "O2 Pulse";
+
+ expect(vm.isCustomThemeApplied("light")).toBe(false);
+ });
+ });
+
+ describe("applyTheme — dark mode branch", () => {
+ it("stores appliedDarkThemeName in localStorage when applying a dark theme", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ const theme = vm.predefinedThemes.find((t: any) => t.name === "O2 Pulse");
+ vm.applyTheme(theme, "dark");
+ await nextTick();
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
+ "appliedDarkThemeName",
+ "O2 Pulse",
+ );
+ });
+
+ it("sets appliedDarkThemeName ref when applying a dark theme", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ const theme = vm.predefinedThemes.find(
+ (t: any) => t.name === "O2 Horizon",
+ );
+ vm.applyTheme(theme, "dark");
+ await nextTick();
+
+ expect(vm.appliedDarkThemeName).toBe("O2 Horizon");
+ });
+
+ it("updates customDarkColor ref to the theme color when applying dark", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ const theme = vm.predefinedThemes.find(
+ (t: any) => t.name === "O2 Beacon",
+ );
+ vm.applyTheme(theme, "dark");
+ await nextTick();
+
+ expect(vm.customDarkColor).toBe(theme.dark.themeColor);
+ });
+
+ it("stores semanticColors JSON in localStorage when applying O2 Crimson Ink dark", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ const theme = vm.predefinedThemes.find(
+ (t: any) => t.name === "O2 Crimson Ink",
+ );
+ vm.applyTheme(theme, "dark");
+ await nextTick();
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
+ "darkSemanticColors",
+ JSON.stringify(theme.dark.semanticColors),
+ );
+ });
+ });
+
+ describe("applyTheme — semantic-less theme removes stale semantic localStorage key", () => {
+ it("calls localStorage.removeItem for semantic key when theme has no semanticColors", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ const theme = vm.predefinedThemes.find((t: any) => t.name === "O2 Pulse");
+ // O2 Pulse has no semanticColors — removeItem should be called for lightSemanticColors
+ vm.applyTheme(theme, "light");
+ await nextTick();
+
+ expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
+ "lightSemanticColors",
+ );
+ });
+ });
+
+ describe("Watch handlers — isOpen composable", () => {
+ it("syncs dialogOpen when isOpen ref changes", async () => {
+ const { usePredefinedThemes } = await import(
+ "@/composables/usePredefinedThemes"
+ );
+
+ // Start with isOpen=false so dialogOpen starts false, then flip to true
+ const reactiveIsOpen = ref(false);
+ vi.mocked(usePredefinedThemes).mockReturnValueOnce({
+ isOpen: reactiveIsOpen,
+ toggleThemes: vi.fn(),
+ } as any);
+
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ // Initially closed (isOpen=false → dialogOpen=false, watch hasn't fired yet)
+ expect(vm.dialogOpen).toBe(false);
+
+ // Simulate composable opening the dialog
+ reactiveIsOpen.value = true;
+ await nextTick();
+
+ expect(vm.dialogOpen).toBe(true);
+
+ // Simulate composable closing the dialog (covers the else/false branch)
+ reactiveIsOpen.value = false;
+ await nextTick();
+
+ expect(vm.dialogOpen).toBe(false);
+ });
+
+ it("syncs activeTab from store theme when isOpen becomes true", async () => {
+ const { usePredefinedThemes } = await import(
+ "@/composables/usePredefinedThemes"
+ );
+
+ mockStore.state.theme = "dark";
+ const reactiveIsOpen = ref(false);
+ vi.mocked(usePredefinedThemes).mockReturnValueOnce({
+ isOpen: reactiveIsOpen,
+ toggleThemes: vi.fn(),
+ } as any);
+
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+ // Initially closed — activeTab is "light" (default)
+ expect(vm.activeTab).toBe("light");
+
+ // Open the dialog — watcher should sync activeTab to store theme (dark)
+ reactiveIsOpen.value = true;
+ await nextTick();
+
+ expect(vm.activeTab).toBe("dark");
+ });
+ });
+
+ describe("Watch handlers — reactive Vuex store", () => {
+ // These tests use the real Vuex store so watchers on store.state actually fire.
+ const createWrapperWithRealStore = async () => {
+ const { default: realStore } = await import(
+ "@/test/unit/helpers/store"
+ );
+ return {
+ store: realStore,
+ wrapper: mount(PredefinedThemes, {
+ global: {
+ plugins: [i18n, realStore],
+ stubs: {
+ ODrawer: {
+ name: "ODrawer",
+ template:
+ '
',
+ props: ["open", "size", "seamless", "title"],
+ emits: ["update:open"],
+ },
+ ODialog: {
+ name: "ODialog",
+ template:
+ 'Apply
',
+ props: ["open", "size", "title", "primaryButtonLabel", "neutralButtonLabel"],
+ emits: ["update:open", "click:primary", "click:neutral"],
+ },
+ OToggleGroup: {
+ name: "OToggleGroup",
+ template: '
',
+ props: ["modelValue", "type"],
+ emits: ["update:modelValue"],
+ },
+ OToggleGroupItem: {
+ name: "OToggleGroupItem",
+ template:
+ ' ',
+ props: ["value", "iconLeft"],
+ emits: ["click"],
+ inheritAttrs: false,
+ },
+ OBadge: {
+ name: "OBadge",
+ template: ' ',
+ props: ["variant", "size", "icon"],
+ inheritAttrs: false,
+ },
+ OButton: {
+ template: ' ',
+ props: ["variant", "size"],
+ emits: ["click"],
+ inheritAttrs: false,
+ },
+ OCardSection: { template: '
' },
+ OSeparator: { template: ' ' },
+ OIcon: { template: ' ', props: ["name", "size"] },
+ OColor: {
+ template: '
',
+ props: ["modelValue"],
+ emits: ["update:modelValue"],
+ },
+ },
+ },
+ }),
+ };
+ };
+
+ it("updates activeTab when store.state.theme changes", async () => {
+ // Real test store starts with theme:"dark".
+ // First mount — activeTab starts at "light" (component default).
+ // Switch the mock store to "light" first so the watcher transition goes light→dark.
+ const { store, wrapper } = await createWrapperWithRealStore();
+ const vm = wrapper.vm as any;
+
+ // activeTab starts at "light" (the component's initial ref value)
+ expect(vm.activeTab).toBe("light");
+
+ // Commit "dark" — store.state.theme changes from "dark"→"dark" (no change) …
+ // so first reset to "light" so the next commit is a real change
+ store.commit("appTheme", "light");
+ await nextTick();
+ // No change: activeTab was already "light"
+ expect(vm.activeTab).toBe("light");
+
+ // Now change to "dark" — watcher should fire and update activeTab
+ store.commit("appTheme", "dark");
+ await nextTick();
+
+ expect(vm.activeTab).toBe("dark");
+ wrapper.unmount();
+ });
+
+ it("does not change activeTab when store.state.theme matches current activeTab", async () => {
+ const { store, wrapper } = await createWrapperWithRealStore();
+ const vm = wrapper.vm as any;
+
+ // activeTab starts at "light" — set store to "light" then commit "light" again
+ store.commit("appTheme", "light");
+ await nextTick();
+ expect(vm.activeTab).toBe("light");
+
+ // Commit same value — guard should prevent activeTab from re-assignment
+ store.commit("appTheme", "light");
+ await nextTick();
+ expect(vm.activeTab).toBe("light");
+ wrapper.unmount();
+ });
+
+ it("updates customLightColor when organizationSettings changes and no localStorage color", async () => {
+ const { store, wrapper } = await createWrapperWithRealStore();
+ const vm = wrapper.vm as any;
+
+ mockLocalStorage.getItem.mockReturnValue(null);
+ // Commit new org settings — fires the deep watcher
+ store.commit("setOrganizationSettings", {
+ light_mode_theme_color: "#AABBCC",
+ dark_mode_theme_color: "#112233",
+ });
+ await nextTick();
+ await nextTick(); // deep watcher may need an extra tick
+
+ expect(vm.customLightColor).toBe("#AABBCC");
+ wrapper.unmount();
+ });
+
+ it("does not update customLightColor when hasTempColors is set in orgSettings watcher", async () => {
+ const { store, wrapper } = await createWrapperWithRealStore();
+ const vm = wrapper.vm as any;
+
+ // Simulate temp colors being active in the store
+ (store.state as any).tempThemeColors = { light: "#FF0000", dark: null };
+ mockLocalStorage.getItem.mockReturnValue(null);
+
+ const colorBefore = vm.customLightColor;
+
+ store.commit("setOrganizationSettings", {
+ light_mode_theme_color: "#DEADBE",
+ dark_mode_theme_color: "#EFFACE",
+ });
+ await nextTick();
+ await nextTick();
+
+ // hasTempColors guard should have returned early
+ expect(vm.customLightColor).toBe(colorBefore);
+ wrapper.unmount();
+ });
+ });
+
+ describe("Watch handlers — activeTab", () => {
+ it("dispatches appTheme when activeTab changes to a different theme mode", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ // store.state.theme starts as "light"; changing to dark should dispatch
+ vm.activeTab = "dark";
+ await nextTick();
+
+ expect(mockStore.dispatch).toHaveBeenCalledWith("appTheme", "dark");
+ });
+
+ it("does not dispatch appTheme when activeTab matches current store.state.theme", async () => {
+ // Set store theme to dark before mounting so they start in sync
+ mockStore.state.theme = "dark";
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ // activeTab defaults to "light" (store override not used by default during mount)
+ // but the component syncs from store.state.theme — let's directly set activeTab to dark
+ // first to match the store
+ vm.activeTab = "dark";
+ await nextTick();
+ vi.clearAllMocks();
+
+ // Now set it to the same value — watcher should not dispatch again
+ vm.activeTab = "dark";
+ await nextTick();
+
+ expect(mockStore.dispatch).not.toHaveBeenCalled();
+ });
+
+ it("sets localStorage theme key when activeTab changes", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.activeTab = "dark";
+ await nextTick();
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith("theme", "dark");
+ });
+ });
+
+ describe("Lifecycle — MutationObserver and onUnmounted", () => {
+ it("disconnects the MutationObserver on unmount without throwing", async () => {
+ const wrapper = createWrapper();
+
+ // Unmounting must not throw even if observer is null
+ await wrapper.unmount();
+ // If we got here without an error, the guard `if (observer)` worked
+ expect(true).toBe(true);
+ });
+
+ it("triggers applyThemeForMode when body class changes to body--dark", async () => {
+ const { applyThemeForMode } = await import("@/utils/themeManager");
+ const wrapper = createWrapper();
+ // Wait for onMounted to set up the observer
+ await nextTick();
+
+ // Reset so we can check for new calls
+ vi.clearAllMocks();
+
+ // Simulate the body class toggle (the MutationObserver watches this)
+ document.body.classList.add("body--dark");
+ // MutationObserver fires asynchronously in a microtask
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ // applyThemeForMode should have been called by the observer callback
+ expect(applyThemeForMode).toHaveBeenCalledWith("dark", expect.anything());
+
+ // Cleanup
+ document.body.classList.remove("body--dark");
+ wrapper.unmount();
+ });
+
+ it("does NOT call applyThemeForMode when temp colors exist (observer hasTempColors guard)", async () => {
+ const { applyThemeForMode } = await import("@/utils/themeManager");
+
+ // Mount with temp colors active
+ mockStore.state.tempThemeColors = { light: "#FF0000", dark: null };
+ const wrapper = createWrapper();
+ await nextTick();
+ vi.clearAllMocks();
+
+ // Trigger the MutationObserver
+ document.body.classList.add("body--dark");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ // With hasTempColors=true the observer returns early — no call
+ expect(applyThemeForMode).not.toHaveBeenCalled();
+
+ // Cleanup
+ document.body.classList.remove("body--dark");
+ wrapper.unmount();
+ });
});
describe("Dialog Controls", () => {
- it("should pass title 'Predefined Themes' to ODrawer", () => {
+ it("should pass title 'Theme' to ODrawer", () => {
const wrapper = createWrapper();
+
const drawer = wrapper.find('[data-test-stub="o-drawer"]');
expect(drawer.exists()).toBe(true);
- expect(drawer.attributes("data-title")).toBe("Predefined Themes");
+ expect(drawer.attributes("data-title")).toBe("Theme");
});
it("should pass title 'Pick Custom Color' to inner ODialog", () => {
const wrapper = createWrapper();
+
const dialog = wrapper.find('[data-test-stub="o-dialog"]');
expect(dialog.exists()).toBe(true);
expect(dialog.attributes("data-title")).toBe("Pick Custom Color");
@@ -567,6 +1450,34 @@ describe("PredefinedThemes", () => {
expect(vm.showColorPicker).toBe(false);
});
+ it("ODialog primary button label is 'Apply'", () => {
+ const wrapper = createWrapper();
+
+ const primaryBtn = wrapper.find('[data-test-stub="o-dialog-primary"]');
+ expect(primaryBtn.text()).toBe("Apply");
+ });
+
+ it("ODialog neutral button label is 'Cancel'", () => {
+ const wrapper = createWrapper();
+
+ const neutralBtn = wrapper.find('[data-test-stub="o-dialog-neutral"]');
+ expect(neutralBtn.text()).toBe("Cancel");
+ });
+
+ it("should close color picker when ODialog emits click:neutral", async () => {
+ const wrapper = createWrapper();
+ const vm = wrapper.vm as any;
+
+ vm.showColorPicker = true;
+ await nextTick();
+
+ const neutralBtn = wrapper.find('[data-test-stub="o-dialog-neutral"]');
+ await neutralBtn.trigger("click");
+ await nextTick();
+
+ expect(vm.showColorPicker).toBe(false);
+ });
+
it("should close drawer when dialogOpen is set to false", async () => {
const wrapper = createWrapper();
const vm = wrapper.vm as any;
diff --git a/web/src/components/PredefinedThemes.vue b/web/src/components/PredefinedThemes.vue
index fe2c59f301..c4cf4c4e1a 100644
--- a/web/src/components/PredefinedThemes.vue
+++ b/web/src/components/PredefinedThemes.vue
@@ -1,4 +1,4 @@
-
+
+ (activeTab = (v as string))"
+ >
+ Light
+ Dark
+
+
-
-
-
-
-
-
-
-
-
{{ theme.name }}
-
- {{ theme.light.themeColor }}
-
-
-
-
Applied
-
Apply
-
-
-
-
-
-
-
-
-
-
-
Custom Color
-
- {{ customLightColor }}
-
-
-
-
Applied
-
Apply
-
-
-
-
-
-
-
-
-
-
-
{{ theme.name }}
-
- {{ theme.dark.themeColor }}
-
-
-
-
Applied
-
Apply
-
-
-
-
-
-
-
-
-
-
-
Custom Color
-
- {{ customDarkColor }}
-
-
-
-
Applied
-
Apply
-
-
-
-
-
-
-
-
-
-
-
-
Theme preferences are stored locally on this device and will not
- sync across different browsers or devices.
+
+
-
+
+
+ {{ theme.name }}
+ {{ theme[mode].themeColor }}
+
+ Applied
+
+
+
+
+
+
+
+
+
+
+ Custom Color
+
+ {{ isCustomThemeApplied(mode)
+ ? (mode === 'light' ? customLightColor : customDarkColor)
+ : 'Pick any brand hex' }}
+
+
+ Applied
+
+
+
+
+
+
+
+
+
+
+ Saved to this device only — themes don't sync across different
+ browsers or devices.
+
+
.