This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 220480b627 feat(theming): add base theme config (#35220)
220480b627 is described below

commit 220480b627c43c9ead3207a0f13ba79e56e4f96c
Author: Gabriel Torres Ruiz <[email protected]>
AuthorDate: Tue Sep 30 14:01:31 2025 -0400

    feat(theming): add base theme config (#35220)
---
 .../src/components/Typography/Typography.test.tsx  |   2 +-
 .../superset-ui-core/src/theme/GlobalStyles.tsx    |  12 +
 .../superset-ui-core/src/theme/Theme.test.tsx      | 596 ++++++++++++++++++++-
 .../packages/superset-ui-core/src/theme/Theme.tsx  | 103 +---
 .../packages/superset-ui-core/src/theme/types.ts   |   1 +
 .../src/theme/utils/themeUtils.test.ts             | 118 +++-
 .../superset-ui-core/src/theme/utils/themeUtils.ts |  25 +-
 superset-frontend/src/pages/ThemeList/index.tsx    | 212 +++++---
 superset-frontend/src/theme/ThemeController.ts     | 160 +++---
 superset/config.py                                 |  95 ++--
 superset/daos/theme.py                             |  43 +-
 superset/views/base.py                             | 116 ++--
 tests/unit_tests/daos/test_theme_dao.py            | 140 ++---
 tests/unit_tests/views/test_base_theme_helpers.py  | 532 ++++++++++++++++++
 14 files changed, 1700 insertions(+), 455 deletions(-)

diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/Typography/Typography.test.tsx
 
b/superset-frontend/packages/superset-ui-core/src/components/Typography/Typography.test.tsx
index 3aa2bb05c0..5382c87bdc 100644
--- 
a/superset-frontend/packages/superset-ui-core/src/components/Typography/Typography.test.tsx
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/Typography/Typography.test.tsx
@@ -50,7 +50,7 @@ describe('Typography Component', () => {
 
   it('renders strong text', () => {
     render(<Typography.Text strong>Strong Text</Typography.Text>);
-    expect(screen.getByText('Strong Text')).toHaveStyle('font-weight: 500');
+    expect(screen.getByText('Strong Text')).toHaveStyle('font-weight: 600');
   });
 
   it('renders underlined text', () => {
diff --git 
a/superset-frontend/packages/superset-ui-core/src/theme/GlobalStyles.tsx 
b/superset-frontend/packages/superset-ui-core/src/theme/GlobalStyles.tsx
index d9936471af..31cf1547f5 100644
--- a/superset-frontend/packages/superset-ui-core/src/theme/GlobalStyles.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/theme/GlobalStyles.tsx
@@ -16,6 +16,18 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+
+// @fontsource/* v5.1+ doesn't play nice with eslint-import plugin v2.31+
+/* eslint-disable import/extensions */
+import '@fontsource/inter/200.css';
+import '@fontsource/inter/400.css';
+import '@fontsource/inter/500.css';
+import '@fontsource/inter/600.css';
+import '@fontsource/fira-code/400.css';
+import '@fontsource/fira-code/500.css';
+import '@fontsource/fira-code/600.css';
+/* eslint-enable import/extensions */
+
 import { css, useTheme, Global } from '@emotion/react';
 
 export const GlobalStyles = () => {
diff --git 
a/superset-frontend/packages/superset-ui-core/src/theme/Theme.test.tsx 
b/superset-frontend/packages/superset-ui-core/src/theme/Theme.test.tsx
index 288f1f6dbe..7b16e59289 100644
--- a/superset-frontend/packages/superset-ui-core/src/theme/Theme.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/theme/Theme.test.tsx
@@ -49,14 +49,12 @@ describe('Theme', () => {
   });
 
   describe('fromConfig', () => {
-    it('creates a theme with default tokens when no config is provided', () => 
{
+    it('creates a theme with Ant Design defaults when no config is provided', 
() => {
       const theme = Theme.fromConfig();
 
-      // Verify default primary color is set
-      expect(theme.theme.colorPrimary).toBe('#2893b3');
-
-      // Verify default font family is set
-      expect(theme.theme.fontFamily).toContain('Inter');
+      // Verify Ant Design default tokens are set
+      expect(theme.theme.colorPrimary).toBeDefined();
+      expect(theme.theme.fontFamily).toBeDefined();
 
       // Verify the theme is initialized with semantic color tokens
       expect(theme.theme.colorText).toBeDefined();
@@ -79,8 +77,8 @@ describe('Theme', () => {
       // Verify custom font family is set
       expect(theme.theme.fontFamily).toBe('CustomFont, sans-serif');
 
-      // But default tokens should still be preserved for unspecified values
-      expect(theme.theme.colorError).toBe('#e04355');
+      // Unspecified values will use Ant Design defaults
+      expect(theme.theme.colorError).toBeDefined();
     });
 
     it('creates a theme with dark mode when dark algorithm is specified', () 
=> {
@@ -205,4 +203,586 @@ describe('Theme', () => {
       expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
     });
   });
+
+  describe('fromConfig with baseTheme', () => {
+    it('applies base theme tokens under the main config', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#ff0000',
+          colorError: '#00ff00',
+          fontFamily: 'BaseFont',
+        },
+      };
+
+      const userConfig: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#0000ff',
+        },
+      };
+
+      const theme = Theme.fromConfig(userConfig, baseTheme);
+
+      // User config overrides base theme
+      expect(theme.theme.colorPrimary).toBe('#0000ff');
+
+      // Base theme tokens are preserved when not overridden
+      expect(theme.theme.colorError).toBe('#00ff00');
+      expect(theme.theme.fontFamily).toBe('BaseFont');
+    });
+
+    it('applies base theme when no user config is provided', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#ff0000',
+          fontFamily: 'TestFont',
+        },
+        algorithm: antdThemeImport.darkAlgorithm,
+      };
+
+      const theme = Theme.fromConfig(undefined, baseTheme);
+
+      // Color may be transformed by dark algorithm, check fontFamily instead
+      expect(theme.theme.fontFamily).toBe('TestFont');
+
+      const serialized = theme.toSerializedConfig();
+      expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
+    });
+
+    it('handles empty config with base theme', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#ff0000',
+        },
+        algorithm: antdThemeImport.defaultAlgorithm,
+      };
+
+      const emptyConfig: AnyThemeConfig = {};
+
+      const theme = Theme.fromConfig(emptyConfig, baseTheme);
+
+      // Base theme tokens should be applied
+      expect(theme.theme.colorPrimary).toBe('#ff0000');
+
+      const serialized = theme.toSerializedConfig();
+      expect(serialized.algorithm).toBe(ThemeAlgorithm.DEFAULT);
+    });
+
+    it('merges algorithms correctly with base theme', () => {
+      const baseTheme: AnyThemeConfig = {
+        algorithm: antdThemeImport.compactAlgorithm,
+      };
+
+      const userConfig: AnyThemeConfig = {
+        algorithm: antdThemeImport.darkAlgorithm,
+      };
+
+      const theme = Theme.fromConfig(userConfig, baseTheme);
+
+      // User algorithm should override base algorithm
+      const serialized = theme.toSerializedConfig();
+      expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
+    });
+
+    it('merges component overrides with base theme', () => {
+      const baseTheme: AnyThemeConfig = {
+        components: {
+          Button: {
+            colorPrimary: '#basebutton',
+          },
+          Input: {
+            colorBorder: '#baseinput',
+          },
+        },
+      };
+
+      const userConfig: AnyThemeConfig = {
+        components: {
+          Button: {
+            colorPrimary: '#userbutton',
+          },
+        },
+      };
+
+      const theme = Theme.fromConfig(userConfig, baseTheme);
+      const serialized = theme.toSerializedConfig();
+
+      // User component config overrides base
+      expect(serialized.components?.Button?.colorPrimary).toBe('#userbutton');
+
+      // Base component config preserved when not overridden
+      expect(serialized.components?.Input?.colorBorder).toBe('#baseinput');
+    });
+
+    it('handles undefined config and undefined base theme', () => {
+      const theme = Theme.fromConfig(undefined, undefined);
+
+      // Should get Ant Design defaults
+      expect(theme.theme.colorPrimary).toBeDefined();
+      expect(theme.theme.fontFamily).toBeDefined();
+    });
+
+    it('preserves custom tokens in base theme', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#ff0000',
+          // Custom superset-specific tokens
+          brandLogoAlt: 'CustomLogo',
+          menuHoverBackgroundColor: '#00ff00',
+        } as Record<string, any>,
+      };
+
+      const theme = Theme.fromConfig({}, baseTheme);
+
+      // Standard token
+      expect(theme.theme.colorPrimary).toBe('#ff0000');
+
+      // Custom tokens should be preserved
+      expect((theme.theme as any).brandLogoAlt).toBe('CustomLogo');
+      expect((theme.theme as any).menuHoverBackgroundColor).toBe('#00ff00');
+    });
+  });
+
+  describe('edge cases with base theme and dark mode', () => {
+    it('correctly applies base theme tokens in dark mode', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#1890ff',
+          fontFamily: 'TestFont',
+        },
+        algorithm: antdThemeImport.defaultAlgorithm,
+      };
+
+      const baseThemeDark: AnyThemeConfig = {
+        ...baseTheme,
+        algorithm: antdThemeImport.darkAlgorithm,
+      };
+
+      // Simulate light mode with base theme
+      const lightTheme = Theme.fromConfig({}, baseTheme);
+      expect(lightTheme.theme.colorPrimary).toBe('#1890ff');
+      expect(lightTheme.theme.fontFamily).toBe('TestFont');
+
+      // Simulate dark mode with base theme dark
+      const darkTheme = Theme.fromConfig({}, baseThemeDark);
+      // Dark algorithm transforms colors, but fontFamily should be preserved
+      expect(darkTheme.theme.fontFamily).toBe('TestFont');
+
+      // Verify the algorithm is different
+      const lightSerialized = lightTheme.toSerializedConfig();
+      const darkSerialized = darkTheme.toSerializedConfig();
+      expect(lightSerialized.algorithm).toBe(ThemeAlgorithm.DEFAULT);
+      expect(darkSerialized.algorithm).toBe(ThemeAlgorithm.DARK);
+    });
+
+    it('handles switching from custom theme back to base theme', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#1890ff',
+        },
+        algorithm: antdThemeImport.defaultAlgorithm,
+      };
+
+      // First apply custom theme
+      const customConfig: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#52c41a',
+        },
+      };
+      const themeWithCustom = Theme.fromConfig(customConfig, baseTheme);
+      expect(themeWithCustom.theme.colorPrimary).toBe('#52c41a');
+
+      // Then switch back to empty config (simulating removal of custom theme)
+      const themeWithEmpty = Theme.fromConfig({}, baseTheme);
+      expect(themeWithEmpty.theme.colorPrimary).toBe('#1890ff');
+
+      // Verify they produce different outputs
+      expect(themeWithCustom.theme.colorPrimary).not.toBe(
+        themeWithEmpty.theme.colorPrimary,
+      );
+    });
+
+    it('handles algorithm-only config with base theme', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          fontFamily: 'TestFont',
+          borderRadius: 8,
+        },
+        algorithm: antdThemeImport.defaultAlgorithm,
+      };
+
+      // Config that only specifies algorithm (common for THEME_DARK)
+      const algorithmOnlyConfig: AnyThemeConfig = {
+        algorithm: antdThemeImport.darkAlgorithm,
+      };
+
+      const theme = Theme.fromConfig(algorithmOnlyConfig, baseTheme);
+
+      // Should have base theme tokens
+      expect(theme.theme.fontFamily).toBe('TestFont');
+      expect(theme.theme.borderRadius).toBe(8);
+
+      // Should have user's algorithm
+      const serialized = theme.toSerializedConfig();
+      expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
+    });
+  });
+
+  describe('base theme integration tests', () => {
+    it('merges base theme tokens with empty user theme', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+          colorError: '#e04355',
+          fontFamily: 'Inter, Helvetica',
+        },
+      };
+
+      const userTheme: AnyThemeConfig = {
+        algorithm: antdThemeImport.defaultAlgorithm,
+      };
+
+      const theme = Theme.fromConfig(userTheme, baseTheme);
+
+      expect(theme.theme.colorPrimary).toBe('#2893B3');
+      expect(theme.theme.colorError).toBe('#e04355');
+      expect(theme.theme.fontFamily).toBe('Inter, Helvetica');
+
+      // Should have user's algorithm
+      const serialized = theme.toSerializedConfig();
+      expect(serialized.algorithm).toBe(ThemeAlgorithm.DEFAULT);
+    });
+
+    it('allows user theme to override specific base theme tokens', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+          colorError: '#e04355',
+          fontFamily: 'Inter, Helvetica',
+          borderRadius: 4,
+        },
+      };
+
+      const userTheme: AnyThemeConfig = {
+        algorithm: antdThemeImport.defaultAlgorithm,
+        token: {
+          colorPrimary: '#123456', // Override primary color
+          // Leave other tokens from base
+        },
+      };
+
+      const theme = Theme.fromConfig(userTheme, baseTheme);
+
+      // User override should win
+      expect(theme.theme.colorPrimary).toBe('#123456');
+
+      // Base theme tokens should be preserved
+      expect(theme.theme.colorError).toBe('#e04355');
+      expect(theme.theme.fontFamily).toBe('Inter, Helvetica');
+      expect(theme.theme.borderRadius).toBe(4);
+    });
+
+    it('handles base theme with dark algorithm correctly', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+          fontFamily: 'Inter, Helvetica',
+        },
+      };
+
+      const baseThemeDark: AnyThemeConfig = {
+        ...baseTheme,
+        algorithm: antdThemeImport.darkAlgorithm,
+      };
+
+      const userDarkTheme: AnyThemeConfig = {
+        algorithm: antdThemeImport.darkAlgorithm,
+      };
+
+      const theme = Theme.fromConfig(userDarkTheme, baseThemeDark);
+
+      // Should have base tokens
+      expect(theme.theme.fontFamily).toBe('Inter, Helvetica');
+
+      // Should be in dark mode
+      const serialized = theme.toSerializedConfig();
+      expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
+    });
+
+    it('works with real-world Superset base theme configuration', () => {
+      // Simulate actual Superset base theme (THEME_DEFAULT/THEME_DARK from 
config)
+      const supersetBaseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+          colorError: '#e04355',
+          colorWarning: '#fcc700',
+          colorSuccess: '#5ac189',
+          colorInfo: '#66bcfe',
+          fontFamily: "'Inter', Helvetica, Arial",
+          fontFamilyCode: "'Fira Code', 'Courier New', monospace",
+        },
+      };
+
+      // Simulate THEME_DEFAULT from config
+      const themeDefault: AnyThemeConfig = {
+        algorithm: antdThemeImport.defaultAlgorithm,
+      };
+
+      // Simulate THEME_DARK from config
+      const themeDark: AnyThemeConfig = {
+        algorithm: antdThemeImport.darkAlgorithm,
+      };
+
+      // Test light mode
+      const lightTheme = Theme.fromConfig(themeDefault, supersetBaseTheme);
+      expect(lightTheme.theme.colorPrimary).toBe('#2893B3');
+      expect(lightTheme.theme.fontFamily).toBe("'Inter', Helvetica, Arial");
+
+      // Test dark mode
+      const darkTheme = Theme.fromConfig(themeDark, {
+        ...supersetBaseTheme,
+        algorithm: antdThemeImport.darkAlgorithm,
+      });
+      expect(darkTheme.theme.fontFamily).toBe("'Inter', Helvetica, Arial");
+
+      const darkSerialized = darkTheme.toSerializedConfig();
+      expect(darkSerialized.algorithm).toBe(ThemeAlgorithm.DARK);
+    });
+
+    it('handles component overrides in base theme', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+        },
+        components: {
+          Button: {
+            primaryColor: '#custom-button',
+            borderRadius: 8,
+          },
+        },
+      };
+
+      const userTheme: AnyThemeConfig = {
+        algorithm: antdThemeImport.defaultAlgorithm,
+      };
+
+      const theme = Theme.fromConfig(userTheme, baseTheme);
+
+      // Should preserve component overrides
+      const serialized = theme.toSerializedConfig();
+      expect(serialized.components?.Button?.primaryColor).toBe(
+        '#custom-button',
+      );
+      expect(serialized.components?.Button?.borderRadius).toBe(8);
+    });
+
+    it('properly handles algorithm property override', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+        },
+        algorithm: antdThemeImport.defaultAlgorithm,
+      };
+
+      const userTheme: AnyThemeConfig = {
+        algorithm: antdThemeImport.darkAlgorithm,
+        token: {
+          borderRadius: 8,
+        },
+      };
+
+      const theme = Theme.fromConfig(userTheme, baseTheme);
+      const serialized = theme.toSerializedConfig();
+
+      // User algorithm should override base algorithm
+      expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
+
+      // Both base and user tokens should be merged
+      expect(serialized.token?.colorPrimary).toBeTruthy();
+      expect(serialized.token?.borderRadius).toBe(8);
+    });
+
+    it('handles cssVar, hashed and inherit properties correctly', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+        },
+        cssVar: true,
+        hashed: false,
+      };
+
+      const userTheme: AnyThemeConfig = {
+        token: {
+          borderRadius: 8,
+        },
+        inherit: true,
+      };
+
+      const theme = Theme.fromConfig(userTheme, baseTheme);
+      const serialized = theme.toSerializedConfig();
+
+      // User properties override/add to base
+      expect(serialized.inherit).toBe(true);
+      expect(serialized.cssVar).toBe(true);
+      expect(serialized.hashed).toBe(false);
+
+      // Tokens are still merged
+      expect(serialized.token?.colorPrimary).toBeTruthy();
+      expect(serialized.token?.borderRadius).toBe(8);
+    });
+
+    it('merges nested component styles correctly', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+          fontFamily: 'BaseFont',
+        },
+        components: {
+          Button: {
+            colorPrimary: '#basebutton',
+            fontSize: 14,
+          },
+          Input: {
+            colorBorder: '#baseinput',
+          },
+        },
+      };
+
+      const userTheme: AnyThemeConfig = {
+        token: {
+          borderRadius: 8,
+        },
+        components: {
+          Button: {
+            fontSize: 16, // Override Button fontSize
+          },
+          Select: {
+            colorBorder: '#userselect', // Add new component
+          },
+        },
+      };
+
+      const theme = Theme.fromConfig(userTheme, baseTheme);
+      const serialized = theme.toSerializedConfig();
+
+      // Tokens should be merged
+      // Note: components present may affect color transformation
+      expect(serialized.token?.colorPrimary).toBeTruthy();
+      expect(serialized.token?.borderRadius).toBe(8);
+      expect(serialized.token?.fontFamily).toBe('BaseFont');
+
+      // Components should be merged (shallow merge per component)
+      expect(serialized.components?.Button?.colorPrimary).toBe('#basebutton');
+      expect(serialized.components?.Button?.fontSize).toBe(16); // User 
override
+      expect(serialized.components?.Input?.colorBorder).toBe('#baseinput');
+      expect(serialized.components?.Select?.colorBorder).toBe('#userselect');
+    });
+
+    it('setConfig replaces theme config entirely (does not preserve base 
theme)', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+          fontFamily: 'Inter',
+        },
+      };
+
+      const theme = Theme.fromConfig({}, baseTheme);
+
+      expect(theme.theme.colorPrimary).toBe('#2893B3');
+      expect(theme.theme.fontFamily).toBe('Inter');
+
+      // Update config (simulating theme change)
+      theme.setConfig({
+        token: {
+          colorPrimary: '#654321',
+        },
+        algorithm: antdThemeImport.darkAlgorithm,
+      });
+
+      // setConfig replaces the entire config, so base theme is NOT preserved
+      // This is expected behavior - setConfig is for complete replacement
+      expect(theme.theme.colorPrimary).toBeTruthy();
+      // fontFamily reverts to Ant Design default since base theme is not 
reapplied
+      expect(theme.theme.fontFamily).not.toBe('Inter');
+    });
+
+    it('minimal theme preserves ALL base theme tokens except overridden ones', 
() => {
+      // Simulate a comprehensive base theme with many tokens
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+          colorError: '#e04355',
+          colorWarning: '#fcc700',
+          colorSuccess: '#5ac189',
+          colorInfo: '#66bcfe',
+          fontFamily: 'Inter, Helvetica',
+          fontSize: 14,
+          borderRadius: 4,
+          lineWidth: 1,
+          controlHeight: 32,
+          // Custom Superset tokens
+          brandLogoAlt: 'CustomLogo',
+          menuHoverBackgroundColor: '#eeeeee',
+        } as Record<string, any>,
+        algorithm: antdThemeImport.defaultAlgorithm,
+      };
+
+      // Minimal theme that only overrides primary color and algorithm
+      const minimalTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#ff05dd', // Only override this
+        },
+        algorithm: antdThemeImport.darkAlgorithm, // Change to dark
+      };
+
+      const theme = Theme.fromConfig(minimalTheme, baseTheme);
+
+      // User's override should apply
+      expect(theme.theme.colorPrimary).toBe('#ff05dd');
+
+      // ALL base theme tokens should be preserved
+      expect(theme.theme.colorError).toBe('#e04355');
+      expect(theme.theme.colorWarning).toBe('#fcc700');
+      expect(theme.theme.colorSuccess).toBe('#5ac189');
+      expect(theme.theme.colorInfo).toBe('#66bcfe');
+      expect(theme.theme.fontFamily).toBe('Inter, Helvetica');
+      expect(theme.theme.fontSize).toBe(14);
+      expect(theme.theme.borderRadius).toBe(4);
+      expect(theme.theme.lineWidth).toBe(1);
+      expect(theme.theme.controlHeight).toBe(32);
+
+      // Custom tokens should also be preserved
+      expect((theme.theme as any).brandLogoAlt).toBe('CustomLogo');
+      expect((theme.theme as any).menuHoverBackgroundColor).toBe('#eeeeee');
+
+      // Algorithm should be updated
+      const serialized = theme.toSerializedConfig();
+      expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
+    });
+
+    it('arrays in themes are replaced entirely, not merged by index', () => {
+      const baseTheme: AnyThemeConfig = {
+        token: {
+          colorPrimary: '#2893B3',
+        },
+        algorithm: [
+          antdThemeImport.compactAlgorithm,
+          antdThemeImport.defaultAlgorithm,
+        ],
+      };
+
+      const userTheme: AnyThemeConfig = {
+        algorithm: [antdThemeImport.darkAlgorithm], // Replace with single 
item array
+      };
+
+      const theme = Theme.fromConfig(userTheme, baseTheme);
+      const serialized = theme.toSerializedConfig();
+
+      // User's array should completely replace base array
+      expect(Array.isArray(serialized.algorithm)).toBe(true);
+      expect(serialized.algorithm).toHaveLength(1);
+      expect(serialized.algorithm).toContain(ThemeAlgorithm.DARK);
+      expect(serialized.algorithm).not.toContain(ThemeAlgorithm.COMPACT);
+      expect(serialized.algorithm).not.toContain(ThemeAlgorithm.DEFAULT);
+    });
+  });
 });
diff --git a/superset-frontend/packages/superset-ui-core/src/theme/Theme.tsx 
b/superset-frontend/packages/superset-ui-core/src/theme/Theme.tsx
index 308eda8cce..77bbecda71 100644
--- a/superset-frontend/packages/superset-ui-core/src/theme/Theme.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/theme/Theme.tsx
@@ -20,31 +20,13 @@
 // eslint-disable-next-line no-restricted-syntax
 import React from 'react';
 import { theme as antdThemeImport, ConfigProvider } from 'antd';
-
-// @fontsource/* v5.1+ doesn't play nice with eslint-import plugin v2.31+
-/* eslint-disable import/extensions */
-import '@fontsource/inter/200.css';
-/* eslint-disable import/extensions */
-import '@fontsource/inter/400.css';
-/* eslint-disable import/extensions */
-import '@fontsource/inter/500.css';
-/* eslint-disable import/extensions */
-import '@fontsource/inter/600.css';
-/* eslint-disable import/extensions */
-import '@fontsource/fira-code/400.css';
-/* eslint-disable import/extensions */
-import '@fontsource/fira-code/500.css';
-/* eslint-disable import/extensions */
-import '@fontsource/fira-code/600.css';
-
 import {
   ThemeProvider,
   CacheProvider as EmotionCacheProvider,
 } from '@emotion/react';
 import createCache from '@emotion/cache';
-import { noop } from 'lodash';
+import { noop, mergeWith } from 'lodash';
 import { GlobalStyles } from './GlobalStyles';
-
 import {
   AntdThemeConfig,
   AnyThemeConfig,
@@ -53,63 +35,16 @@ import {
   allowedAntdTokens,
   SharedAntdTokens,
 } from './types';
-
 import { normalizeThemeConfig, serializeThemeConfig } from './utils';
 
-/* eslint-disable theme-colors/no-literal-colors */
-
 export class Theme {
   theme: SupersetTheme;
 
-  private static readonly defaultTokens = {
-    // Brand
-    brandLogoAlt: 'Apache Superset',
-    brandLogoUrl: '/static/assets/images/superset-logo-horiz.png',
-    brandLogoMargin: '18px',
-    brandLogoHref: '/',
-    brandLogoHeight: '24px',
-
-    // Spinner
-    brandSpinnerUrl: undefined,
-    brandSpinnerSvg: undefined,
-
-    // Default colors
-    colorPrimary: '#2893B3', // NOTE: previous lighter primary color was 
#20a7c9
-    colorLink: '#2893B3',
-    colorError: '#e04355',
-    colorWarning: '#fcc700',
-    colorSuccess: '#5ac189',
-    colorInfo: '#66bcfe',
-
-    // Forcing some default tokens
-    fontFamily: `'Inter', Helvetica, Arial`,
-    fontFamilyCode: `'Fira Code', 'Courier New', monospace`,
-
-    // Extra tokens
-    transitionTiming: 0.3,
-    brandIconMaxWidth: 37,
-    fontSizeXS: '8',
-    fontSizeXXL: '28',
-    fontWeightNormal: '400',
-    fontWeightLight: '300',
-    fontWeightStrong: 500,
-  };
-
   private antdConfig: AntdThemeConfig;
 
   private constructor({ config }: { config?: AnyThemeConfig }) {
     this.SupersetThemeProvider = this.SupersetThemeProvider.bind(this);
-
-    // Create a new config object with default tokens
-    const newConfig: AnyThemeConfig = config ? { ...config } : {};
-
-    // Ensure token property exists with defaults
-    newConfig.token = {
-      ...Theme.defaultTokens,
-      ...(config?.token || {}),
-    };
-
-    this.setConfig(newConfig);
+    this.setConfig(config || {});
   }
 
   /**
@@ -118,9 +53,24 @@ export class Theme {
    * If simple tokens are provided as { token: {...} }, they will be applied 
with defaults
    * If no config is provided, uses default tokens
    * Dark mode can be set via the algorithm property in the config
+   * @param config - The theme configuration
+   * @param baseTheme - Optional base theme to apply under the config
    */
-  static fromConfig(config?: AnyThemeConfig): Theme {
-    return new Theme({ config });
+  static fromConfig(
+    config?: AnyThemeConfig,
+    baseTheme?: AnyThemeConfig,
+  ): Theme {
+    let mergedConfig: AnyThemeConfig | undefined = config;
+
+    if (baseTheme && config) {
+      mergedConfig = mergeWith({}, baseTheme, config, (objValue, srcValue) =>
+        Array.isArray(srcValue) ? srcValue : undefined,
+      );
+    } else if (baseTheme && !config) {
+      mergedConfig = baseTheme;
+    }
+
+    return new Theme({ config: mergedConfig });
   }
 
   private static getFilteredAntdTheme(
@@ -148,22 +98,15 @@ export class Theme {
   setConfig(config: AnyThemeConfig): void {
     const antdConfig = normalizeThemeConfig(config);
 
-    // Apply default tokens to token property
-    antdConfig.token = {
-      ...Theme.defaultTokens,
-      ...(antdConfig.token || {}),
-    };
-
     // First phase: Let Ant Design compute the tokens
     const tokens = Theme.getFilteredAntdTheme(antdConfig);
 
     // Set the base theme properties
     this.antdConfig = antdConfig;
     this.theme = {
-      ...Theme.defaultTokens,
-      ...antdConfig.token, // Passing through the extra, superset-specific 
tokens
-      ...tokens,
-    };
+      ...tokens, // First apply Ant Design computed tokens
+      ...(antdConfig.token || {}), // Then override with our custom tokens
+    } as SupersetTheme;
 
     // Update the providers with the fully formed theme
     this.updateProviders(
@@ -250,5 +193,3 @@ export class Theme {
     );
   }
 }
-
-/* eslint-enable theme-colors/no-literal-colors */
diff --git a/superset-frontend/packages/superset-ui-core/src/theme/types.ts 
b/superset-frontend/packages/superset-ui-core/src/theme/types.ts
index b9e2a000ea..f4ae4f64df 100644
--- a/superset-frontend/packages/superset-ui-core/src/theme/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/theme/types.ts
@@ -78,6 +78,7 @@ export type SerializableThemeConfig = {
   algorithm?: ThemeAlgorithmOption;
   hashed?: boolean;
   inherit?: boolean;
+  cssVar?: boolean | { key?: string; prefix?: string };
 };
 
 /**
diff --git 
a/superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.test.ts
 
b/superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.test.ts
index e72f8291cd..da48a93c79 100644
--- 
a/superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.test.ts
+++ 
b/superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.test.ts
@@ -16,7 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { getFontSize, getColorVariants, isThemeDark } from './themeUtils';
+import { theme as antdTheme } from 'antd';
+import {
+  getFontSize,
+  getColorVariants,
+  isThemeDark,
+  isThemeConfigDark,
+} from './themeUtils';
 import { Theme } from '../Theme';
 import { ThemeAlgorithm } from '../types';
 
@@ -71,8 +77,7 @@ describe('themeUtils', () => {
         token: { fontSize: '14' },
       });
 
-      // Ant Design provides fontSizeXS: '8' by default
-      expect(getFontSize(minimalTheme.theme, 'xs')).toBe('8');
+      expect(getFontSize(minimalTheme.theme, 'xs')).toBe('14');
       expect(getFontSize(minimalTheme.theme, 'm')).toBe('14');
     });
   });
@@ -131,4 +136,111 @@ describe('themeUtils', () => {
       expect(variants.bg).toBeUndefined();
     });
   });
+
+  describe('isThemeConfigDark', () => {
+    it('returns true for config with dark algorithm', () => {
+      const config = {
+        algorithm: antdTheme.darkAlgorithm,
+      };
+      expect(isThemeConfigDark(config)).toBe(true);
+    });
+
+    it('returns true for config with dark algorithm in array', () => {
+      const config = {
+        algorithm: [antdTheme.darkAlgorithm, antdTheme.compactAlgorithm],
+      };
+      expect(isThemeConfigDark(config)).toBe(true);
+    });
+
+    it('returns false for config without dark algorithm', () => {
+      const config = {
+        algorithm: antdTheme.defaultAlgorithm,
+      };
+      expect(isThemeConfigDark(config)).toBe(false);
+    });
+
+    it('returns false for config with no algorithm', () => {
+      const config = {
+        token: {
+          colorPrimary: '#1890ff',
+        },
+      };
+      expect(isThemeConfigDark(config)).toBe(false);
+    });
+
+    it('detects manually-created dark theme without dark algorithm', () => {
+      // This is the edge case: dark colors without dark algorithm
+      const config = {
+        token: {
+          colorBgContainer: '#1a1a1a', // Dark background
+          colorBgBase: '#0a0a0a', // Dark base
+          colorText: '#ffffff', // Light text
+        },
+      };
+      expect(isThemeConfigDark(config)).toBe(true);
+    });
+
+    it('does not false-positive on light theme with custom colors', () => {
+      const config = {
+        token: {
+          colorBgContainer: '#ffffff', // Light background
+          colorBgBase: '#f5f5f5', // Light base
+          colorText: '#000000', // Dark text
+        },
+      };
+      expect(isThemeConfigDark(config)).toBe(false);
+    });
+
+    it('handles partial color tokens gracefully', () => {
+      // With actual theme computation, a dark colorBgContainer results in a 
dark theme
+      const config = {
+        token: {
+          colorBgContainer: '#1a1a1a', // Dark background
+          // Missing other color tokens
+        },
+      };
+      expect(isThemeConfigDark(config)).toBe(true);
+    });
+
+    it('respects colorBgContainer as the primary indicator', () => {
+      // The computed theme uses colorBgContainer as the main background
+      const darkConfig = {
+        token: {
+          colorBgContainer: '#1a1a1a', // Dark background
+          colorText: '#000000', // Dark text (unusual but doesn't override)
+        },
+      };
+      expect(isThemeConfigDark(darkConfig)).toBe(true);
+
+      const lightConfig = {
+        token: {
+          colorBgContainer: '#ffffff', // Light background
+          colorText: '#ffffff', // Light text (unusual but doesn't override)
+        },
+      };
+      expect(isThemeConfigDark(lightConfig)).toBe(false);
+    });
+
+    it('handles non-string color tokens gracefully', () => {
+      const config = {
+        token: {
+          colorBgContainer: undefined,
+          colorText: null,
+          colorBgBase: 123, // Invalid type
+        },
+      };
+      expect(isThemeConfigDark(config)).toBe(false);
+    });
+
+    it('returns false for empty config', () => {
+      expect(isThemeConfigDark({})).toBe(false);
+    });
+
+    it('returns false for config with empty token object', () => {
+      const config = {
+        token: {},
+      };
+      expect(isThemeConfigDark(config)).toBe(false);
+    });
+  });
 });
diff --git 
a/superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.ts 
b/superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.ts
index 36859cc887..a896a7b5fa 100644
--- a/superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.ts
+++ b/superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.ts
@@ -18,7 +18,14 @@
  */
 import tinycolor from 'tinycolor2';
 import { useTheme as useEmotionTheme } from '@emotion/react';
-import type { SupersetTheme, FontSizeKey, ColorVariants } from '../types';
+import { theme as antdTheme } from 'antd';
+import type {
+  SupersetTheme,
+  FontSizeKey,
+  ColorVariants,
+  AnyThemeConfig,
+} from '../types';
+import { normalizeThemeConfig } from '../utils';
 
 const fontSizeMap: Record<FontSizeKey, keyof SupersetTheme> = {
   xs: 'fontSizeXS',
@@ -113,6 +120,22 @@ export function isThemeDark(theme: SupersetTheme): boolean 
{
   return tinycolor(theme.colorBgContainer).isDark();
 }
 
+/**
+ * Check if a theme configuration results in a dark theme
+ * @param config - The theme configuration to check
+ * @returns true if the config results in a dark theme, false otherwise
+ */
+export function isThemeConfigDark(config: AnyThemeConfig): boolean {
+  try {
+    const normalizedConfig = normalizeThemeConfig(config);
+    const themeConfig = antdTheme.getDesignToken(normalizedConfig);
+
+    return tinycolor(themeConfig.colorBgContainer).isDark();
+  } catch {
+    return false;
+  }
+}
+
 /**
  * Hook to determine if the current theme is dark mode
  * @returns true if theme is dark, false if light
diff --git a/superset-frontend/src/pages/ThemeList/index.tsx 
b/superset-frontend/src/pages/ThemeList/index.tsx
index 8c570f46cf..a9a29d4eef 100644
--- a/superset-frontend/src/pages/ThemeList/index.tsx
+++ b/superset-frontend/src/pages/ThemeList/index.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { t, SupersetClient, styled } from '@superset-ui/core';
 import {
   Tag,
@@ -72,12 +72,26 @@ const FlexRowContainer = styled.div`
   }
 `;
 
+const IconTag = styled(Tag)`
+  display: inline-flex;
+  align-items: center;
+`;
+
 const CONFIRM_OVERWRITE_MESSAGE = t(
   'You are importing one or more themes that already exist. ' +
     'Overwriting might cause you to lose some of your work. Are you ' +
     'sure you want to overwrite?',
 );
 
+interface ConfirmModalConfig {
+  visible: boolean;
+  title: string;
+  message: string;
+  onConfirm: () => Promise<any>;
+  successMessage: string;
+  errorMessage: string;
+}
+
 interface ThemesListProps {
   addDangerToast: (msg: string) => void;
   addSuccessToast: (msg: string) => void;
@@ -112,6 +126,10 @@ function ThemesList({
   const [importingTheme, showImportModal] = useState<boolean>(false);
   const [appliedThemeId, setAppliedThemeId] = useState<number | null>(null);
 
+  // State for confirmation modal
+  const [confirmModalConfig, setConfirmModalConfig] =
+    useState<ConfirmModalConfig | null>(null);
+
   const canCreate = hasPerm('can_write');
   const canEdit = hasPerm('can_write');
   const canDelete = hasPerm('can_write');
@@ -189,20 +207,23 @@ function ThemesList({
     setThemeModalOpen(true);
   }
 
-  function handleThemeApply(themeObj: ThemeObject) {
-    if (themeObj.json_data) {
-      try {
-        const themeConfig = JSON.parse(themeObj.json_data);
-        setTemporaryTheme(themeConfig);
-        setAppliedThemeId(themeObj.id || null);
-        addSuccessToast(t('Local theme set to "%s"', themeObj.theme_name));
-      } catch (error) {
-        addDangerToast(
-          t('Failed to set local theme: Invalid JSON configuration'),
-        );
+  const handleThemeApply = useCallback(
+    (themeObj: ThemeObject) => {
+      if (themeObj.json_data) {
+        try {
+          const themeConfig = JSON.parse(themeObj.json_data);
+          setTemporaryTheme(themeConfig);
+          setAppliedThemeId(themeObj.id || null);
+          addSuccessToast(t('Local theme set to "%s"', themeObj.theme_name));
+        } catch (error) {
+          addDangerToast(
+            t('Failed to set local theme: Invalid JSON configuration'),
+          );
+        }
       }
-    }
-  }
+    },
+    [setTemporaryTheme, addSuccessToast, addDangerToast],
+  );
 
   function handleThemeModalApply() {
     // Clear any previously applied theme ID when applying from modal
@@ -235,60 +256,83 @@ function ThemesList({
   };
 
   // Generic confirmation modal utility to reduce code duplication
-  const showThemeConfirmation = (config: {
-    title: string;
-    content: string;
-    onConfirm: () => Promise<any>;
-    successMessage: string;
-    errorMessage: string;
-  }) => {
-    Modal.confirm({
-      title: config.title,
-      content: config.content,
-      onOk: () => {
-        config
-          .onConfirm()
-          .then(() => {
-            refreshData();
-            addSuccessToast(config.successMessage);
-          })
-          .catch(err => {
-            addDangerToast(t(config.errorMessage, err.message));
-          });
-      },
-    });
-  };
+  const showThemeConfirmation = useCallback(
+    (config: {
+      title: string;
+      content: string;
+      onConfirm: () => Promise<any>;
+      successMessage: string;
+      errorMessage: string;
+    }) => {
+      setConfirmModalConfig({
+        visible: true,
+        title: config.title,
+        message: config.content,
+        onConfirm: config.onConfirm,
+        successMessage: config.successMessage,
+        errorMessage: config.errorMessage,
+      });
+    },
+    [],
+  );
 
-  const handleSetSystemDefault = (theme: ThemeObject) => {
-    showThemeConfirmation({
-      title: t('Set System Default Theme'),
-      content: t(
-        'Are you sure you want to set "%s" as the system default theme? This 
will apply to all users who haven\'t set a personal preference.',
-        theme.theme_name,
-      ),
-      onConfirm: () => setSystemDefaultTheme(theme.id!),
-      successMessage: t(
-        '"%s" is now the system default theme',
-        theme.theme_name,
-      ),
-      errorMessage: 'Failed to set system default theme: %s',
-    });
+  const handleConfirmModalOk = async () => {
+    if (!confirmModalConfig) return;
+
+    try {
+      await confirmModalConfig.onConfirm();
+      refreshData();
+      addSuccessToast(confirmModalConfig.successMessage);
+      setConfirmModalConfig(null);
+    } catch (err: any) {
+      addDangerToast(t(confirmModalConfig.errorMessage, err.message));
+    }
   };
 
-  const handleSetSystemDark = (theme: ThemeObject) => {
-    showThemeConfirmation({
-      title: t('Set System Dark Theme'),
-      content: t(
-        'Are you sure you want to set "%s" as the system dark theme? This will 
apply to all users who haven\'t set a personal preference.',
-        theme.theme_name,
-      ),
-      onConfirm: () => setSystemDarkTheme(theme.id!),
-      successMessage: t('"%s" is now the system dark theme', theme.theme_name),
-      errorMessage: 'Failed to set system dark theme: %s',
-    });
+  const handleConfirmModalCancel = () => {
+    setConfirmModalConfig(null);
   };
 
-  const handleUnsetSystemDefault = () => {
+  const handleSetSystemDefault = useCallback(
+    (theme: ThemeObject) => {
+      showThemeConfirmation({
+        title: t('Set System Default Theme'),
+        content: t(
+          'Are you sure you want to set "%s" as the system default theme? This 
will apply to all users who haven\'t set a personal preference.',
+          theme.theme_name,
+        ),
+        onConfirm: () => setSystemDefaultTheme(theme.id!),
+        successMessage: t(
+          '"%s" is now the system default theme',
+          theme.theme_name,
+        ),
+        errorMessage: 'Failed to set system default theme: %s',
+      });
+    },
+    [showThemeConfirmation],
+  );
+
+  const handleSetSystemDark = useCallback(
+    (theme: ThemeObject) => {
+      showThemeConfirmation({
+        title: t('Set System Dark Theme'),
+        content: t(
+          'Are you sure you want to set "%s" as the system dark theme? This 
will apply to all users who haven\'t set a personal preference.',
+          theme.theme_name,
+        ),
+        onConfirm: () => setSystemDarkTheme(theme.id!),
+        successMessage: t(
+          '"%s" is now the system dark theme',
+          theme.theme_name,
+          theme.theme_name,
+        ),
+        errorMessage: 'Failed to set system dark theme: %s',
+      });
+    },
+    [showThemeConfirmation],
+  );
+
+  const handleUnsetSystemDefault = useCallback(() => {
     showThemeConfirmation({
       title: t('Remove System Default Theme'),
       content: t(
@@ -298,9 +342,9 @@ function ThemesList({
       successMessage: t('System default theme removed'),
       errorMessage: 'Failed to remove system default theme: %s',
     });
-  };
+  }, [showThemeConfirmation]);
 
-  const handleUnsetSystemDark = () => {
+  const handleUnsetSystemDark = useCallback(() => {
     showThemeConfirmation({
       title: t('Remove System Dark Theme'),
       content: t(
@@ -310,7 +354,7 @@ function ThemesList({
       successMessage: t('System dark theme removed'),
       errorMessage: 'Failed to remove system dark theme: %s',
     });
-  };
+  }, [showThemeConfirmation]);
 
   const initialSort = [{ id: 'theme_name', desc: true }];
   const columns = useMemo(
@@ -340,16 +384,16 @@ function ThemesList({
               )}
               {original.is_system_default && (
                 <Tooltip title={t('This is the system default theme')}>
-                  <Tag color="warning">
-                    <Icons.SunOutlined /> {t('Default')}
-                  </Tag>
+                  <IconTag color="warning" icon={<Icons.SunOutlined />}>
+                    {t('Default')}
+                  </IconTag>
                 </Tooltip>
               )}
               {original.is_system_dark && (
                 <Tooltip title={t('This is the system dark theme')}>
-                  <Tag color="default">
-                    <Icons.MoonOutlined /> {t('Dark')}
-                  </Tag>
+                  <IconTag color="default" icon={<Icons.MoonOutlined />}>
+                    {t('Dark')}
+                  </IconTag>
                 </Tooltip>
               )}
             </FlexRowContainer>
@@ -487,12 +531,19 @@ function ThemesList({
       },
     ],
     [
+      canEdit,
       canDelete,
-      canCreate,
       canApply,
       canExport,
-      canSetSystemThemes,
+      getCurrentCrudThemeId,
       appliedThemeId,
+      canSetSystemThemes,
+      addDangerToast,
+      handleThemeApply,
+      handleSetSystemDefault,
+      handleUnsetSystemDefault,
+      handleSetSystemDark,
+      handleUnsetSystemDark,
     ],
   );
 
@@ -570,7 +621,7 @@ function ThemesList({
         paginate: true,
       },
     ],
-    [],
+    [user],
   );
 
   return (
@@ -681,6 +732,17 @@ function ThemesList({
         }}
       </ConfirmStatusChange>
       {preparingExport && <Loading />}
+      {confirmModalConfig?.visible && (
+        <Modal
+          title={confirmModalConfig.title}
+          show={confirmModalConfig.visible}
+          onHide={handleConfirmModalCancel}
+          onHandledPrimaryAction={handleConfirmModalOk}
+          primaryButtonName={t('Yes')}
+        >
+          {confirmModalConfig.message}
+        </Modal>
+      )}
     </>
   );
 }
diff --git a/superset-frontend/src/theme/ThemeController.ts 
b/superset-frontend/src/theme/ThemeController.ts
index a40dafca5e..c4f297916d 100644
--- a/superset-frontend/src/theme/ThemeController.ts
+++ b/superset-frontend/src/theme/ThemeController.ts
@@ -18,18 +18,15 @@
  */
 import {
   type AnyThemeConfig,
-  type SupersetTheme,
   type SupersetThemeConfig,
   type ThemeControllerOptions,
   type ThemeStorage,
+  isThemeConfigDark,
   Theme,
   ThemeMode,
   themeObject as supersetThemeObject,
 } from '@superset-ui/core';
-import {
-  getAntdConfig,
-  normalizeThemeConfig,
-} from '@superset-ui/core/theme/utils';
+import { normalizeThemeConfig } from '@superset-ui/core/theme/utils';
 import type {
   BootstrapThemeData,
   BootstrapThemeDataConfig,
@@ -79,7 +76,7 @@ export class ThemeController {
 
   private modeStorageKey: string;
 
-  private defaultTheme: AnyThemeConfig;
+  private defaultTheme: AnyThemeConfig | null;
 
   private darkTheme: AnyThemeConfig | null;
 
@@ -87,8 +84,6 @@ export class ThemeController {
 
   private currentMode: ThemeMode;
 
-  private hasCustomThemes: boolean;
-
   private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
 
   private mediaQuery: MediaQueryList;
@@ -116,22 +111,13 @@ export class ThemeController {
     this.globalTheme = themeObject;
 
     // Initialize bootstrap data and themes
-    const {
-      bootstrapDefaultTheme,
-      bootstrapDarkTheme,
-      hasCustomThemes,
-    }: BootstrapThemeData = this.loadBootstrapData();
+    const { bootstrapDefaultTheme, bootstrapDarkTheme }: BootstrapThemeData =
+      this.loadBootstrapData();
 
-    this.hasCustomThemes = hasCustomThemes;
-
-    // Set themes based on bootstrap data availability
-    if (this.hasCustomThemes) {
-      this.darkTheme = bootstrapDarkTheme;
-      this.defaultTheme = bootstrapDefaultTheme || defaultTheme;
-    } else {
-      this.darkTheme = null;
-      this.defaultTheme = defaultTheme;
-    }
+    // Set themes from bootstrap data
+    // These will be the THEME_DEFAULT and THEME_DARK from config
+    this.defaultTheme = bootstrapDefaultTheme || defaultTheme || null;
+    this.darkTheme = bootstrapDarkTheme;
 
     // Initialize system theme detection
     this.systemMode = ThemeController.getSystemPreferredMode();
@@ -147,7 +133,7 @@ export class ThemeController {
     // Initialize theme and mode
     this.currentMode = this.determineInitialMode();
     const initialTheme =
-      this.getThemeForMode(this.currentMode) || this.defaultTheme;
+      this.getThemeForMode(this.currentMode) || this.defaultTheme || {};
 
     // Setup change callback
     if (onChange) this.onChangeCallbacks.add(onChange);
@@ -197,6 +183,7 @@ export class ThemeController {
 
   /**
    * Gets the theme configuration for a specific context (global vs dashboard).
+   * Dashboard themes are always merged with base theme.
    * @param forDashboard - Whether to get the dashboard theme or global theme
    * @returns The theme configuration for the specified context
    */
@@ -205,7 +192,16 @@ export class ThemeController {
   ): AnyThemeConfig | null {
     // For dashboard context, prioritize dashboard CRUD theme
     if (forDashboard && this.dashboardCrudTheme) {
-      return this.dashboardCrudTheme;
+      // Dashboard CRUD themes should be merged with base theme
+      const normalizedTheme = this.normalizeTheme(this.dashboardCrudTheme);
+      const isDarkMode = isThemeConfigDark(normalizedTheme);
+      const baseTheme = isDarkMode ? this.darkTheme : this.defaultTheme;
+
+      if (baseTheme) {
+        const mergedTheme = Theme.fromConfig(normalizedTheme, baseTheme);
+        return mergedTheme.toSerializedConfig();
+      }
+      return normalizedTheme;
     }
 
     // For global context or when no dashboard theme, use mode-based theme
@@ -241,7 +237,15 @@ export class ThemeController {
         // Controller creates and owns the dashboard theme
         const { Theme } = await import('@superset-ui/core');
         const normalizedConfig = this.normalizeTheme(themeConfig);
-        const dashboardTheme = Theme.fromConfig(normalizedConfig);
+
+        // Determine if this is a dark theme and get appropriate base
+        const isDarkMode = isThemeConfigDark(normalizedConfig);
+        const baseTheme = isDarkMode ? this.darkTheme : this.defaultTheme;
+
+        const dashboardTheme = Theme.fromConfig(
+          normalizedConfig,
+          baseTheme || undefined,
+        );
 
         // Cache the theme for reuse
         this.dashboardThemes.set(themeId, dashboardTheme);
@@ -325,7 +329,7 @@ export class ThemeController {
   public resetTheme(): void {
     this.currentMode = ThemeMode.DEFAULT;
     const defaultTheme: AnyThemeConfig =
-      this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme;
+      this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme || {};
 
     this.updateTheme(defaultTheme);
   }
@@ -373,8 +377,8 @@ export class ThemeController {
       JSON.stringify(theme),
     );
 
-    const normalizedTheme = this.normalizeTheme(theme);
-    this.updateTheme(normalizedTheme);
+    const mergedTheme = this.getThemeForMode(this.currentMode);
+    if (mergedTheme) this.updateTheme(mergedTheme);
   }
 
   /**
@@ -384,10 +388,14 @@ export class ThemeController {
   public clearLocalOverrides(): void {
     this.devThemeOverride = null;
     this.crudThemeId = null;
+    this.dashboardCrudTheme = null;
 
     this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
     this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
 
+    // Clear dashboard themes cache
+    this.dashboardThemes.clear();
+
     this.resetTheme();
   }
 
@@ -407,7 +415,7 @@ export class ThemeController {
 
   /**
    * Checks if OS preference detection is allowed.
-   * Allowed when both themes are available
+   * Allowed when dark theme is available (including base dark theme)
    */
   public canDetectOSPreference(): boolean {
     return this.darkTheme !== null;
@@ -422,7 +430,6 @@ export class ThemeController {
   public setThemeConfig(config: SupersetThemeConfig): void {
     this.defaultTheme = config.theme_default;
     this.darkTheme = config.theme_dark || null;
-    this.hasCustomThemes = true;
 
     let newMode: ThemeMode;
     try {
@@ -478,13 +485,16 @@ export class ThemeController {
   private updateTheme(theme?: AnyThemeConfig): void {
     try {
       // If no config provided, use current mode to get theme
-      const config: AnyThemeConfig =
-        theme || this.getThemeForMode(this.currentMode) || this.defaultTheme;
-
-      // Normalize the theme
-      const normalizedTheme = this.normalizeTheme(config);
+      if (!theme) {
+        // No theme provided, use the current mode's theme
+        const modeTheme =
+          this.getThemeForMode(this.currentMode) || this.defaultTheme || {};
+        this.applyTheme(modeTheme);
+      } else {
+        // Theme provided, apply it directly
+        this.applyTheme(theme);
+      }
 
-      this.applyTheme(normalizedTheme);
       this.persistMode();
       this.notifyListeners();
     } catch (error) {
@@ -501,7 +511,7 @@ export class ThemeController {
 
     // Get the default theme which will have the correct algorithm
     const defaultTheme: AnyThemeConfig =
-      this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme;
+      this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme || {};
 
     this.applyTheme(defaultTheme);
     this.persistMode();
@@ -554,10 +564,15 @@ export class ThemeController {
     const hasValidDefault: boolean = this.isNonEmptyObject(defaultTheme);
     const hasValidDark: boolean = this.isNonEmptyObject(darkTheme);
 
+    // Check if themes have actual custom tokens (not just empty or 
algorithm-only)
+    const hasCustomDefault =
+      hasValidDefault && !this.isEmptyTheme(defaultTheme);
+    const hasCustomDark = hasValidDark && !this.isEmptyTheme(darkTheme);
+
     return {
-      bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
-      bootstrapDarkTheme: hasValidDark ? darkTheme : null,
-      hasCustomThemes: hasValidDefault || hasValidDark,
+      bootstrapDefaultTheme: hasCustomDefault ? defaultTheme : null,
+      bootstrapDarkTheme: hasCustomDark ? darkTheme : null,
+      hasCustomThemes: hasCustomDefault || hasCustomDark,
     };
   }
 
@@ -572,6 +587,20 @@ export class ThemeController {
     );
   }
 
+  /**
+   * Checks if a theme is truly empty (not even an algorithm).
+   * A theme with just an algorithm is still valid and should be used.
+   */
+  private isEmptyTheme(theme: AnyThemeConfig | undefined): boolean {
+    if (!theme) return true;
+
+    return !(
+      theme.algorithm ||
+      (theme.token && Object.keys(theme.token).length > 0) ||
+      (theme.components && Object.keys(theme.components).length > 0)
+    );
+  }
+
   /**
    * Normalizes the theme configuration to ensure it has a valid algorithm.
    * @param theme - The theme configuration to normalize
@@ -588,49 +617,45 @@ export class ThemeController {
    * @returns The theme configuration for the specified mode or null if not 
available
    */
   private getThemeForMode(mode: ThemeMode): AnyThemeConfig | null {
-    // Priority 1: Dev theme override (highest priority for development)
-    // Dev overrides affect all contexts
     if (this.devThemeOverride) {
-      return this.devThemeOverride;
+      const normalizedOverride = this.normalizeTheme(this.devThemeOverride);
+      const isDarkMode = isThemeConfigDark(normalizedOverride);
+      const baseTheme = isDarkMode ? this.darkTheme : this.defaultTheme;
+
+      if (baseTheme) {
+        const mergedTheme = Theme.fromConfig(normalizedOverride, baseTheme);
+        return mergedTheme.toSerializedConfig();
+      }
+
+      return normalizedOverride;
     }
 
-    // Priority 2: System theme based on mode (applies to all contexts)
     let resolvedMode: ThemeMode = mode;
 
     if (mode === ThemeMode.SYSTEM) {
-      // OS preference is allowed when dark theme exists
       if (this.darkTheme === null) return null;
       resolvedMode = ThemeController.getSystemPreferredMode();
     }
 
-    if (!this.hasCustomThemes) {
-      const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>;
-      return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK);
-    }
-
-    // Handle bootstrap themes using existing normalization
-    const selectedTheme: AnyThemeConfig =
-      resolvedMode === ThemeMode.DARK
-        ? this.darkTheme || this.defaultTheme
-        : this.defaultTheme;
+    if (resolvedMode === ThemeMode.DARK) return this.darkTheme;
 
-    return selectedTheme;
+    return this.defaultTheme;
   }
 
   /**
    * Determines the initial theme mode with error recovery.
    */
   private determineInitialMode(): ThemeMode {
+    // Try to restore saved mode first
+    const savedMode: ThemeMode | null = this.loadSavedMode();
+    if (savedMode && this.isValidThemeMode(savedMode)) return savedMode;
+
     // If no dark theme is available, force default mode
     if (this.darkTheme === null) {
       this.storage.removeItem(this.modeStorageKey);
       return ThemeMode.DEFAULT;
     }
 
-    // Try to restore saved mode
-    const savedMode: ThemeMode | null = this.loadSavedMode();
-    if (savedMode && this.isValidThemeMode(savedMode)) return savedMode;
-
     // Default to system preference when both themes are available
     return ThemeMode.SYSTEM;
   }
@@ -663,11 +688,14 @@ export class ThemeController {
     // Validate that we have the required theme data for the mode
     switch (mode) {
       case ThemeMode.DARK:
-        return !!(this.darkTheme || this.defaultTheme);
+        // Dark mode is valid if we have a dark theme
+        return !!this.darkTheme;
       case ThemeMode.DEFAULT:
+        // Default mode is valid if we have a default theme
         return !!this.defaultTheme;
       case ThemeMode.SYSTEM:
-        return this.darkTheme !== null;
+        // System mode is valid if dark mode is available
+        return !!this.darkTheme;
       default:
         return true;
     }
@@ -698,11 +726,15 @@ export class ThemeController {
    * Applies the current theme configuration to the global theme.
    * This method sets the theme on the globalTheme and applies it to the Theme.
    * It also handles any errors that may occur during the application of the 
theme.
-   * @param theme - The theme configuration to apply
+   * @param theme - The theme configuration to apply (may already include base 
theme tokens)
    */
   private applyTheme(theme: AnyThemeConfig): void {
     try {
       const normalizedConfig = normalizeThemeConfig(theme);
+
+      // Simply apply the theme - it should already be properly merged if 
needed
+      // The merging with base theme happens in getThemeForMode() and other 
methods
+      // that prepare themes before passing them to applyTheme()
       this.globalTheme.setConfig(normalizedConfig);
     } catch (error) {
       console.error('Failed to apply theme:', error);
diff --git a/superset/config.py b/superset/config.py
index f707642e4a..600b562f56 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -36,7 +36,7 @@ from contextlib import contextmanager
 from datetime import timedelta
 from email.mime.multipart import MIMEMultipart
 from importlib.resources import files
-from typing import Any, Callable, Iterator, Literal, TYPE_CHECKING, TypedDict
+from typing import Any, Callable, Iterator, Literal, Optional, TYPE_CHECKING, 
TypedDict
 
 import click
 from celery.schedules import crontab
@@ -724,46 +724,69 @@ COMMON_BOOTSTRAP_OVERRIDES_FUNC: Callable[  # noqa: E731
 # This is merely a default
 EXTRA_CATEGORICAL_COLOR_SCHEMES: list[dict[str, Any]] = []
 
-# ---------------------------------------------------
-# Theme Configuration for Superset
-# ---------------------------------------------------
+# -----------------------------------------------------------------------------
+# Theme System Configuration
+# -----------------------------------------------------------------------------
 # Superset supports custom theming through Ant Design's theme structure.
-# This allows users to customize colors, fonts, and other UI elements.
-#
-# Theme Generation:
-# - Use the Ant Design theme editor: https://ant.design/theme-editor
-# - Export or copy the generated theme JSON and assign to the variables below
-# - For detailed instructions: 
https://superset.apache.org/docs/configuration/theming/
 #
-# To expose a JSON theme editor modal that can be triggered from the navbar
-# set the `ENABLE_THEME_EDITOR` feature flag to True.
+# Theme Hierarchy:
+# 1. THEME_DEFAULT/THEME_DARK - Base themes defined in config (foundation)
+# 2. System themes - Set by admins via UI (when 
ENABLE_UI_THEME_ADMINISTRATION=True)
+# 3. Dashboard themes - Applied per dashboard using the theme bolt button
 #
-# Theme Structure:
-# Each theme should follow Ant Design's theme format.
-# To create custom themes, use the Ant Design Theme Editor at 
https://ant.design/theme-editor
-# and copy the generated JSON configuration.
+# How it works:
+# - Custom themes override base themes for any properties they define
+# - Properties not defined in custom themes use the base theme values
+# - Admins can set system-wide themes that apply to all users
+# - Users can apply specific themes to individual dashboards
 #
-# Example theme definition:
-# THEME_DEFAULT = {
-#       "token": {
-#            "colorPrimary": "#2893B3",
-#            "colorSuccess": "#5ac189",
-#            "colorWarning": "#fcc700",
-#            "colorError": "#e04355",
-#            "fontFamily": "'Inter', Helvetica, Arial",
-#            ... # other tokens
-#       },
-#       ... # other theme properties
-# }
-
-
-# Default theme configuration
-# Leave empty to use Superset's default theme
-THEME_DEFAULT: Theme = {"algorithm": "default"}
+# Theme Creation:
+# - Use the Ant Design theme editor: https://ant.design/theme-editor
+# - Export the generated JSON and use it in your theme configuration
+# -----------------------------------------------------------------------------
+
+# Default theme configuration - foundation for all themes
+# This acts as the base theme for all users
+THEME_DEFAULT: Theme = {
+    "token": {
+        # Brand
+        "brandLogoAlt": "Apache Superset",
+        "brandLogoUrl": APP_ICON,
+        "brandLogoMargin": "18px",
+        "brandLogoHref": "/",
+        "brandLogoHeight": "24px",
+        # Spinner
+        "brandSpinnerUrl": None,
+        "brandSpinnerSvg": None,
+        # Default colors
+        "colorPrimary": "#2893B3",  # NOTE: previous lighter primary color was 
#20a7c9 # noqa: E501
+        "colorLink": "#2893B3",
+        "colorError": "#e04355",
+        "colorWarning": "#fcc700",
+        "colorSuccess": "#5ac189",
+        "colorInfo": "#66bcfe",
+        # Fonts
+        "fontFamily": "Inter, Helvetica, Arial",
+        "fontFamilyCode": "'Fira Code', 'Courier New', monospace",
+        # Extra tokens
+        "transitionTiming": 0.3,
+        "brandIconMaxWidth": 37,
+        "fontSizeXS": "8",
+        "fontSizeXXL": "28",
+        "fontWeightNormal": "400",
+        "fontWeightLight": "300",
+        "fontWeightStrong": "500",
+    },
+    "algorithm": "default",
+}
 
-# Dark theme configuration
-# Applied when user selects dark mode
-THEME_DARK: Theme = {"algorithm": "dark"}
+# Dark theme configuration - foundation for dark mode
+# Inherits all tokens from THEME_DEFAULT and adds dark algorithm
+# Set to None to disable dark mode
+THEME_DARK: Optional[Theme] = {
+    **THEME_DEFAULT,
+    "algorithm": "dark",
+}
 
 # Theme behavior and user preference settings
 # To force a single theme on all users, set THEME_DARK = None
diff --git a/superset/daos/theme.py b/superset/daos/theme.py
index 839b9f74f3..2734071dfb 100644
--- a/superset/daos/theme.py
+++ b/superset/daos/theme.py
@@ -32,11 +32,11 @@ class ThemeDAO(BaseDAO[Theme]):
 
     @classmethod
     def find_system_default(cls) -> Optional[Theme]:
-        """Find the current system default theme.
-
-        First looks for a theme with is_system_default=True.
-        If not found or multiple found, falls back to is_system=True theme
-        with name 'THEME_DEFAULT'.
+        """
+        Find the current system default theme.
+        Returns the theme with is_system_default=True if exactly one exists.
+        Returns None if no theme or multiple themes have
+        is_system_default=True, which triggers fallback to config.py theme.
         """
         system_defaults = (
             
db.session.query(Theme).filter(Theme.is_system_default.is_(True)).all()
@@ -45,27 +45,15 @@ class ThemeDAO(BaseDAO[Theme]):
         if len(system_defaults) == 1:
             return system_defaults[0]
 
-        if len(system_defaults) > 1:
-            logger.warning(
-                "Multiple system default themes found (%s), "
-                "falling back to config theme",
-                len(system_defaults),
-            )
-
-        # Fallback to is_system=True theme with name 'THEME_DEFAULT'
-        return (
-            db.session.query(Theme)
-            .filter(Theme.is_system.is_(True), Theme.theme_name == 
"THEME_DEFAULT")
-            .first()
-        )
+        return None
 
     @classmethod
     def find_system_dark(cls) -> Optional[Theme]:
         """Find the current system dark theme.
 
-        First looks for a theme with is_system_dark=True.
-        If not found or multiple found, falls back to is_system=True theme
-        with name 'THEME_DARK'.
+        Returns the theme with is_system_dark=True if exactly one exists.
+        Returns None if no theme or multiple themes have is_system_dark=True,
+        which triggers fallback to config.py theme.
         """
         system_darks = (
             
db.session.query(Theme).filter(Theme.is_system_dark.is_(True)).all()
@@ -74,15 +62,4 @@ class ThemeDAO(BaseDAO[Theme]):
         if len(system_darks) == 1:
             return system_darks[0]
 
-        if len(system_darks) > 1:
-            logger.warning(
-                "Multiple system dark themes found (%s), falling back to 
config theme",
-                len(system_darks),
-            )
-
-        # Fallback to is_system=True theme with name 'THEME_DARK'
-        return (
-            db.session.query(Theme)
-            .filter(Theme.is_system.is_(True), Theme.theme_name == 
"THEME_DARK")
-            .first()
-        )
+        return None
diff --git a/superset/views/base.py b/superset/views/base.py
index cb9676c632..bf493628ad 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -21,7 +21,7 @@ import logging
 import os
 import traceback
 from datetime import datetime
-from typing import Any, Callable
+from typing import Any, Callable, cast
 
 from babel import Locale
 from flask import (
@@ -58,8 +58,10 @@ from superset.daos.theme import ThemeDAO
 from superset.db_engine_specs import get_available_engine_specs
 from superset.db_engine_specs.gsheets import GSheetsEngineSpec
 from superset.extensions import cache_manager
+from superset.models.core import Theme as ThemeModel
 from superset.reports.models import ReportRecipientType
 from superset.superset_typing import FlaskResponse
+from superset.themes.types import Theme, ThemeMode
 from superset.themes.utils import (
     is_valid_theme,
 )
@@ -310,6 +312,57 @@ def menu_data(user: User) -> dict[str, Any]:
     }
 
 
+def _merge_theme_dicts(base: dict[str, Any], overlay: dict[str, Any]) -> 
dict[str, Any]:
+    """
+    Recursively merge overlay theme dict into base theme dict.
+    Arrays and non-dict values are replaced, not merged.
+    """
+    result = base.copy()
+    for key, value in overlay.items():
+        if isinstance(result.get(key), dict) and isinstance(value, dict):
+            result[key] = _merge_theme_dicts(result[key], value)
+        else:
+            result[key] = value
+    return result
+
+
+def _load_theme_from_model(
+    theme_model: ThemeModel | None,
+    fallback_theme: Theme | None,
+    theme_type: ThemeMode,
+) -> Theme | None:
+    """Load and parse theme from database model, merging with config theme as 
base."""
+    if theme_model:
+        try:
+            db_theme = json.loads(theme_model.json_data)
+            if fallback_theme:
+                merged = _merge_theme_dicts(dict(fallback_theme), db_theme)
+                return cast(Theme, merged)
+            return db_theme
+        except json.JSONDecodeError:
+            logger.error(
+                "Invalid JSON in system %s theme %s", theme_type.value, 
theme_model.id
+            )
+            return fallback_theme
+    return fallback_theme
+
+
+def _process_theme(theme: Theme | None, theme_type: ThemeMode) -> Theme:
+    """Process and validate a theme, returning an empty dict if invalid."""
+    if theme is None or theme == {}:
+        # When config theme is None or empty, don't provide a custom theme
+        # The frontend will use base theme only
+        return {}
+    elif not is_valid_theme(cast(dict[str, Any], theme)):
+        logger.warning(
+            "Invalid %s theme configuration: %s, clearing it",
+            theme_type.value,
+            theme,
+        )
+        return {}
+    return theme or {}
+
+
 def get_theme_bootstrap_data() -> dict[str, Any]:
     """
     Returns the theme data to be sent to the client.
@@ -317,59 +370,30 @@ def get_theme_bootstrap_data() -> dict[str, Any]:
     # Check if UI theme administration is enabled
     enable_ui_admin = app.config.get("ENABLE_UI_THEME_ADMINISTRATION", False)
 
+    # Get config themes to use as fallback
+    config_theme_default = get_config_value("THEME_DEFAULT")
+    config_theme_dark = get_config_value("THEME_DARK")
+
     if enable_ui_admin:
         # Try to load themes from database
         default_theme_model = ThemeDAO.find_system_default()
         dark_theme_model = ThemeDAO.find_system_dark()
 
         # Parse theme JSON from database models
-        default_theme = {}
-        if default_theme_model:
-            try:
-                default_theme = json.loads(default_theme_model.json_data)
-            except json.JSONDecodeError:
-                logger.error(
-                    "Invalid JSON in system default theme %s",
-                    default_theme_model.id,
-                )
-                # Fallback to config
-                default_theme = get_config_value("THEME_DEFAULT")
-        else:
-            # No system default theme in database, use config
-            default_theme = get_config_value("THEME_DEFAULT")
-
-        dark_theme = {}
-        if dark_theme_model:
-            try:
-                dark_theme = json.loads(dark_theme_model.json_data)
-            except json.JSONDecodeError:
-                logger.error(
-                    "Invalid JSON in system dark theme %s", dark_theme_model.id
-                )
-                # Fallback to config
-                dark_theme = get_config_value("THEME_DARK")
-        else:
-            # No system dark theme in database, use config
-            dark_theme = get_config_value("THEME_DARK")
-    else:
-        # UI theme administration disabled, use config-based themes
-        default_theme = get_config_value("THEME_DEFAULT")
-        dark_theme = get_config_value("THEME_DARK")
-
-    # Validate theme configurations
-    if not is_valid_theme(default_theme):
-        logger.warning(
-            "Invalid default theme configuration: %s, using empty theme",
-            default_theme,
+        default_theme = _load_theme_from_model(
+            default_theme_model, config_theme_default, ThemeMode.DEFAULT
         )
-        default_theme = {}
-
-    if not is_valid_theme(dark_theme):
-        logger.warning(
-            "Invalid dark theme configuration: %s, using empty theme",
-            dark_theme,
+        dark_theme = _load_theme_from_model(
+            dark_theme_model, config_theme_dark, ThemeMode.DARK
         )
-        dark_theme = {}
+    else:
+        # UI theme administration disabled - use config-based themes
+        default_theme = config_theme_default
+        dark_theme = config_theme_dark
+
+    # Process and validate themes
+    default_theme = _process_theme(default_theme, ThemeMode.DEFAULT)
+    dark_theme = _process_theme(dark_theme, ThemeMode.DARK)
 
     return {
         "theme": {
diff --git a/tests/unit_tests/daos/test_theme_dao.py 
b/tests/unit_tests/daos/test_theme_dao.py
index 54b4666a56..bf748fbfb2 100644
--- a/tests/unit_tests/daos/test_theme_dao.py
+++ b/tests/unit_tests/daos/test_theme_dao.py
@@ -45,8 +45,7 @@ class TestThemeDAO:
         assert result == mock_theme
 
     @patch("superset.daos.theme.db.session")
-    @patch("superset.daos.theme.logger")
-    def test_find_system_default_multiple(self, mock_logger, mock_session):
+    def test_find_system_default_multiple(self, mock_session):
         """Test finding system default theme when multiple exist"""
         # Create mock themes
         mock_theme1 = MagicMock(spec=Theme)
@@ -54,69 +53,33 @@ class TestThemeDAO:
         mock_theme2 = MagicMock(spec=Theme)
         mock_theme2.is_system_default = True
 
-        # Create mock fallback theme
-        mock_fallback = MagicMock(spec=Theme)
-        mock_fallback.is_system = True
-        mock_fallback.theme_name = "THEME_DEFAULT"
-
-        # Mock the query chains - need separate mocks for each query call
-        mock_query1 = MagicMock()
-        mock_query2 = MagicMock()
-        mock_session.query.side_effect = [mock_query1, mock_query2]
-
-        # First query returns multiple themes
-        mock_filter1 = MagicMock()
-        mock_query1.filter.return_value = mock_filter1
-        mock_filter1.all.return_value = [mock_theme1, mock_theme2]
-
-        # Second query returns fallback theme
-        mock_filter2 = MagicMock()
-        mock_query2.filter.return_value = mock_filter2
-        mock_filter2.first.return_value = mock_fallback
+        # Mock the query chain
+        mock_query = MagicMock()
+        mock_session.query.return_value = mock_query
+        mock_filter = MagicMock()
+        mock_query.filter.return_value = mock_filter
+        mock_filter.all.return_value = [mock_theme1, mock_theme2]
 
         # Call the method
         result = ThemeDAO.find_system_default()
 
-        # Verify warning was logged with lazy logging format
-        mock_logger.warning.assert_called_once()
-        call_args = mock_logger.warning.call_args
-        assert (
-            call_args[0][0]
-            == "Multiple system default themes found (%s), falling back to 
config theme"
-        )
-        assert call_args[0][1] == 2
-
-        # Verify the result is the fallback theme
-        assert result == mock_fallback
+        assert result is None
 
     @patch("superset.daos.theme.db.session")
     def test_find_system_default_none(self, mock_session):
         """Test finding system default theme when none exist"""
-        # Create mock fallback theme
-        mock_fallback = MagicMock(spec=Theme)
-        mock_fallback.is_system = True
-        mock_fallback.theme_name = "THEME_DEFAULT"
-
-        # Mock the query chains - need separate mocks for each query call
-        mock_query1 = MagicMock()
-        mock_query2 = MagicMock()
-        mock_session.query.side_effect = [mock_query1, mock_query2]
-
-        # First query returns no themes
-        mock_filter1 = MagicMock()
-        mock_query1.filter.return_value = mock_filter1
-        mock_filter1.all.return_value = []
-
-        # Second query returns fallback theme
-        mock_filter2 = MagicMock()
-        mock_query2.filter.return_value = mock_filter2
-        mock_filter2.first.return_value = mock_fallback
+        # Mock the query chain
+        mock_query = MagicMock()
+        mock_session.query.return_value = mock_query
+        mock_filter = MagicMock()
+        mock_query.filter.return_value = mock_filter
+        mock_filter.all.return_value = []
 
         # Call the method
         result = ThemeDAO.find_system_default()
 
-        # Verify the result is the fallback theme
-        assert result == mock_fallback
+        # Verify the result is None (no fallback)
+        assert result is None
 
     @patch("superset.daos.theme.db.session")
     def test_find_system_dark_single(self, mock_session):
@@ -139,8 +102,7 @@ class TestThemeDAO:
         assert result == mock_theme
 
     @patch("superset.daos.theme.db.session")
-    @patch("superset.daos.theme.logger")
-    def test_find_system_dark_multiple(self, mock_logger, mock_session):
+    def test_find_system_dark_multiple(self, mock_session):
         """Test finding system dark theme when multiple exist"""
         # Create mock themes
         mock_theme1 = MagicMock(spec=Theme)
@@ -148,69 +110,33 @@ class TestThemeDAO:
         mock_theme2 = MagicMock(spec=Theme)
         mock_theme2.is_system_dark = True
 
-        # Create mock fallback theme
-        mock_fallback = MagicMock(spec=Theme)
-        mock_fallback.is_system = True
-        mock_fallback.theme_name = "THEME_DARK"
-
-        # Mock the query chains - need separate mocks for each query call
-        mock_query1 = MagicMock()
-        mock_query2 = MagicMock()
-        mock_session.query.side_effect = [mock_query1, mock_query2]
-
-        # First query returns multiple themes
-        mock_filter1 = MagicMock()
-        mock_query1.filter.return_value = mock_filter1
-        mock_filter1.all.return_value = [mock_theme1, mock_theme2]
-
-        # Second query returns fallback theme
-        mock_filter2 = MagicMock()
-        mock_query2.filter.return_value = mock_filter2
-        mock_filter2.first.return_value = mock_fallback
+        # Mock the query chain
+        mock_query = MagicMock()
+        mock_session.query.return_value = mock_query
+        mock_filter = MagicMock()
+        mock_query.filter.return_value = mock_filter
+        mock_filter.all.return_value = [mock_theme1, mock_theme2]
 
         # Call the method
         result = ThemeDAO.find_system_dark()
 
-        # Verify warning was logged with lazy logging format
-        mock_logger.warning.assert_called_once()
-        call_args = mock_logger.warning.call_args
-        assert (
-            call_args[0][0]
-            == "Multiple system dark themes found (%s), falling back to config 
theme"
-        )
-        assert call_args[0][1] == 2
-
-        # Verify the result is the fallback theme
-        assert result == mock_fallback
+        assert result is None
 
     @patch("superset.daos.theme.db.session")
     def test_find_system_dark_none_with_fallback(self, mock_session):
-        """Test finding system dark theme when none exist but fallback does"""
-        # Create mock fallback theme
-        mock_fallback = MagicMock(spec=Theme)
-        mock_fallback.is_system = True
-        mock_fallback.theme_name = "THEME_DARK"
-
-        # Mock the query chains - need separate mocks for each query call
-        mock_query1 = MagicMock()
-        mock_query2 = MagicMock()
-        mock_session.query.side_effect = [mock_query1, mock_query2]
-
-        # First query returns no themes
-        mock_filter1 = MagicMock()
-        mock_query1.filter.return_value = mock_filter1
-        mock_filter1.all.return_value = []
-
-        # Second query returns fallback theme
-        mock_filter2 = MagicMock()
-        mock_query2.filter.return_value = mock_filter2
-        mock_filter2.first.return_value = mock_fallback
+        """Test finding system dark theme when none exist"""
+        # Mock the query chain
+        mock_query = MagicMock()
+        mock_session.query.return_value = mock_query
+        mock_filter = MagicMock()
+        mock_query.filter.return_value = mock_filter
+        mock_filter.all.return_value = []
 
         # Call the method
         result = ThemeDAO.find_system_dark()
 
-        # Verify the result is the fallback theme
-        assert result == mock_fallback
+        # Verify the result is None (no fallback)
+        assert result is None
 
     @patch("superset.daos.theme.db.session")
     def test_find_system_dark_none_without_fallback(self, mock_session):
diff --git a/tests/unit_tests/views/test_base_theme_helpers.py 
b/tests/unit_tests/views/test_base_theme_helpers.py
new file mode 100644
index 0000000000..4f29480afd
--- /dev/null
+++ b/tests/unit_tests/views/test_base_theme_helpers.py
@@ -0,0 +1,532 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from unittest.mock import MagicMock, patch
+
+from superset.themes.types import ThemeMode
+from superset.views.base import (
+    _load_theme_from_model,
+    _merge_theme_dicts,
+    _process_theme,
+    get_theme_bootstrap_data,
+)
+
+
+class TestThemeHelpers:
+    """Test theme helper functions in views/base.py"""
+
+    def test_merge_theme_dicts_simple(self):
+        """Test merging simple theme dictionaries"""
+        base = {"token": {"colorPrimary": "#000"}}
+        overlay = {"token": {"colorPrimary": "#fff"}}
+        result = _merge_theme_dicts(base, overlay)
+        assert result == {"token": {"colorPrimary": "#fff"}}
+
+    def test_merge_theme_dicts_nested(self):
+        """Test merging nested theme dictionaries"""
+        base = {"token": {"colorPrimary": "#000", "fontSize": 14}}
+        overlay = {"token": {"colorPrimary": "#fff"}}
+        result = _merge_theme_dicts(base, overlay)
+        assert result == {"token": {"colorPrimary": "#fff", "fontSize": 14}}
+
+    def test_merge_theme_dicts_algorithm(self):
+        """Test merging theme with algorithm"""
+        base = {"token": {"colorPrimary": "#000"}, "algorithm": "default"}
+        overlay = {"algorithm": "dark"}
+        result = _merge_theme_dicts(base, overlay)
+        assert result == {"token": {"colorPrimary": "#000"}, "algorithm": 
"dark"}
+
+    def test_merge_theme_dicts_arrays_replaced(self):
+        """Test that arrays are replaced, not merged by index"""
+        base = {
+            "token": {"colorPrimary": "#000"},
+            "algorithm": ["default", "compact"],
+            "components": {
+                "Button": {"sizes": ["small", "medium", "large"]},
+            },
+        }
+        overlay = {
+            "algorithm": ["dark"],
+            "components": {
+                "Button": {"sizes": ["xs", "sm"]},
+            },
+        }
+        result = _merge_theme_dicts(base, overlay)
+
+        # Arrays should be completely replaced, not merged
+        assert result["algorithm"] == ["dark"]  # Not ["dark", "compact"]
+        assert result["components"]["Button"]["sizes"] == [
+            "xs",
+            "sm",
+        ]  # Not ["xs", "sm", "large"]
+        assert result["token"]["colorPrimary"] == "#000"  # Preserved
+
+    def test_merge_minimal_theme_preserves_base(self):
+        """Test that minimal theme overlay preserves all base tokens"""
+        # Simulate a full base theme from config
+        base_theme = {
+            "token": {
+                "colorPrimary": "#1890ff",
+                "colorSuccess": "#52c41a",
+                "colorWarning": "#faad14",
+                "colorError": "#f5222d",
+                "fontSize": 14,
+                "borderRadius": 6,
+                "wireframe": False,
+                "colorBgContainer": "#ffffff",
+                "colorText": "#000000",
+            },
+            "algorithm": "default",
+            "components": {
+                "Button": {"colorPrimary": "#1890ff"},
+                "Input": {"borderRadius": 4},
+            },
+        }
+
+        # Minimal overlay theme (like from database)
+        minimal_overlay = {
+            "token": {
+                "colorPrimary": "#ff00ff",  # Only override primary color
+            },
+            "algorithm": "dark",  # Change to dark mode
+        }
+
+        result = _merge_theme_dicts(base_theme, minimal_overlay)
+
+        # Should preserve all base tokens except the ones explicitly overridden
+        assert result["token"]["colorPrimary"] == "#ff00ff"  # Overridden
+        assert result["token"]["colorSuccess"] == "#52c41a"  # Preserved from 
base
+        assert result["token"]["colorWarning"] == "#faad14"  # Preserved from 
base
+        assert result["token"]["colorError"] == "#f5222d"  # Preserved from 
base
+        assert result["token"]["fontSize"] == 14  # Preserved from base
+        assert result["token"]["borderRadius"] == 6  # Preserved from base
+        assert result["token"]["wireframe"] is False  # Preserved from base
+        assert result["token"]["colorBgContainer"] == "#ffffff"  # Preserved 
from base
+        assert result["token"]["colorText"] == "#000000"  # Preserved from base
+        assert result["algorithm"] == "dark"  # Overridden
+        assert result["components"]["Button"]["colorPrimary"] == "#1890ff"  # 
Preserved
+        assert result["components"]["Input"]["borderRadius"] == 4  # Preserved
+
+    def test_merge_complete_theme_replaces_tokens(self):
+        """Test that complete theme overlay replaces all specified tokens"""
+        # Base theme from config
+        base_theme = {
+            "token": {
+                "colorPrimary": "#1890ff",
+                "colorSuccess": "#52c41a",
+                "colorWarning": "#faad14",
+                "fontSize": 14,
+                "borderRadius": 6,
+            },
+            "algorithm": "default",
+        }
+
+        # Complete overlay theme that redefines everything
+        complete_overlay = {
+            "token": {
+                "colorPrimary": "#ff0000",
+                "colorSuccess": "#00ff00",
+                "colorWarning": "#ffff00",
+                "fontSize": 16,
+                "borderRadius": 8,
+                # Adding new tokens not in base
+                "colorInfo": "#0000ff",
+                "lineHeight": 1.5,
+            },
+            "algorithm": "dark",
+            "components": {
+                "Button": {"size": "large"},
+            },
+        }
+
+        result = _merge_theme_dicts(base_theme, complete_overlay)
+
+        # All overlay tokens should replace base tokens
+        assert result["token"]["colorPrimary"] == "#ff0000"
+        assert result["token"]["colorSuccess"] == "#00ff00"
+        assert result["token"]["colorWarning"] == "#ffff00"
+        assert result["token"]["fontSize"] == 16
+        assert result["token"]["borderRadius"] == 8
+        # New tokens should be added
+        assert result["token"]["colorInfo"] == "#0000ff"
+        assert result["token"]["lineHeight"] == 1.5
+        # Algorithm should be replaced
+        assert result["algorithm"] == "dark"
+        # New components should be added
+        assert result["components"]["Button"]["size"] == "large"
+
+    def test_load_theme_from_model_none(self):
+        """Test _load_theme_from_model with None model"""
+        fallback = {"token": {"colorPrimary": "#111"}}
+        result = _load_theme_from_model(None, fallback, "test")
+        assert result == fallback
+
+    def test_load_theme_from_model_minimal_theme(self):
+        """Test _load_theme_from_model with minimal theme that merges with 
base"""
+        mock_model = MagicMock()
+        # Minimal theme from database - only overrides primary color
+        mock_model.json_data = '{"token": {"colorPrimary": "#ff00ff"}}'
+        mock_model.id = 1
+        # Full base theme from config
+        fallback = {
+            "token": {
+                "colorPrimary": "#1890ff",
+                "colorSuccess": "#52c41a",
+                "colorWarning": "#faad14",
+                "fontSize": 14,
+                "borderRadius": 6,
+            },
+            "algorithm": "default",
+        }
+
+        result = _load_theme_from_model(mock_model, fallback, "test")
+
+        # Should merge, preserving base tokens
+        assert result["token"]["colorPrimary"] == "#ff00ff"  # From database
+        assert result["token"]["colorSuccess"] == "#52c41a"  # From base
+        assert result["token"]["colorWarning"] == "#faad14"  # From base
+        assert result["token"]["fontSize"] == 14  # From base
+        assert result["token"]["borderRadius"] == 6  # From base
+        assert result["algorithm"] == "default"  # From base
+
+    def test_load_theme_from_model_complete_theme(self):
+        """Test _load_theme_from_model with complete theme that replaces base 
tokens"""
+        mock_model = MagicMock()
+        # Complete theme from database - redefines all tokens
+        mock_model.json_data = """{
+            "token": {
+                "colorPrimary": "#ff0000",
+                "colorSuccess": "#00ff00",
+                "colorWarning": "#ffff00",
+                "fontSize": 16,
+                "borderRadius": 8,
+                "colorInfo": "#0000ff"
+            },
+            "algorithm": "dark"
+        }"""
+        mock_model.id = 1
+        # Base theme from config
+        fallback = {
+            "token": {
+                "colorPrimary": "#1890ff",
+                "colorSuccess": "#52c41a",
+                "colorWarning": "#faad14",
+                "fontSize": 14,
+                "borderRadius": 6,
+            },
+            "algorithm": "default",
+        }
+
+        result = _load_theme_from_model(mock_model, fallback, "test")
+
+        # All database tokens should replace base tokens
+        assert result["token"]["colorPrimary"] == "#ff0000"  # From database
+        assert result["token"]["colorSuccess"] == "#00ff00"  # From database
+        assert result["token"]["colorWarning"] == "#ffff00"  # From database
+        assert result["token"]["fontSize"] == 16  # From database
+        assert result["token"]["borderRadius"] == 8  # From database
+        assert result["token"]["colorInfo"] == "#0000ff"  # New from database
+        assert result["algorithm"] == "dark"  # From database
+
+    @patch("superset.views.base.logger")
+    def test_load_theme_from_model_invalid_json(self, mock_logger):
+        """Test _load_theme_from_model with invalid JSON"""
+        mock_model = MagicMock()
+        mock_model.json_data = "invalid json{"
+        mock_model.id = 1
+        fallback = {"token": {"colorPrimary": "#111"}}
+
+        result = _load_theme_from_model(mock_model, fallback, 
ThemeMode.DEFAULT)
+        assert result == fallback
+        mock_logger.error.assert_called_once_with(
+            "Invalid JSON in system %s theme %s", "default", 1
+        )
+
+    def test_process_theme_none(self):
+        """Test _process_theme with None theme"""
+        result = _process_theme(None, ThemeMode.DEFAULT)
+        assert result == {}
+
+    def test_process_theme_empty(self):
+        """Test _process_theme with empty theme"""
+        result = _process_theme({}, ThemeMode.DEFAULT)
+        assert result == {}
+
+    @patch("superset.views.base.is_valid_theme")
+    def test_process_theme_invalid(self, mock_is_valid):
+        """Test _process_theme with invalid theme"""
+        mock_is_valid.return_value = False
+        theme = {"invalid": "theme"}
+
+        with patch("superset.views.base.logger") as mock_logger:
+            result = _process_theme(theme, ThemeMode.DEFAULT)
+            assert result == {}
+            mock_logger.warning.assert_called_once_with(
+                "Invalid %s theme configuration: %s, clearing it",
+                "default",
+                theme,
+            )
+
+    @patch("superset.views.base.is_valid_theme")
+    def test_process_theme_valid(self, mock_is_valid):
+        """Test _process_theme with valid theme"""
+        mock_is_valid.return_value = True
+        theme = {"token": {"colorPrimary": "#444"}}
+
+        result = _process_theme(theme, ThemeMode.DEFAULT)
+        assert result == theme
+
+    def test_process_theme_none_returns_empty(self):
+        """Test _process_theme with None returns empty dict"""
+        result = _process_theme(None, ThemeMode.DEFAULT)
+        assert result == {}
+
+
+class TestGetThemeBootstrapData:
+    """Test get_theme_bootstrap_data function with various scenarios"""
+
+    @patch("superset.views.base.app")
+    @patch("superset.views.base.get_config_value")
+    @patch("superset.views.base.ThemeDAO")
+    def test_ui_admin_enabled_with_db_themes(
+        self,
+        mock_dao,
+        mock_get_config,
+        mock_app,
+    ):
+        """Test with UI admin enabled and themes in database"""
+        # Setup
+        mock_app.config = MagicMock()
+        mock_app.config.get.side_effect = lambda k, d=None: {
+            "ENABLE_UI_THEME_ADMINISTRATION": True,
+            "BASE_THEME_DEFAULT": {"token": {"colorPrimary": "#base1"}},
+            "BASE_THEME_DARK": {"token": {"colorPrimary": "#base2"}},
+        }.get(k, d)
+
+        mock_get_config.side_effect = lambda k: {
+            "THEME_DEFAULT": {"token": {"colorPrimary": "#config1"}},
+            "THEME_DARK": {"token": {"colorPrimary": "#config2"}},
+        }.get(k)
+
+        mock_default_theme = MagicMock()
+        mock_default_theme.json_data = '{"token": {"colorPrimary": "#db1"}}'
+        mock_dark_theme = MagicMock()
+        mock_dark_theme.json_data = '{"token": {"colorPrimary": "#db2"}}'
+
+        mock_dao.find_system_default.return_value = mock_default_theme
+        mock_dao.find_system_dark.return_value = mock_dark_theme
+
+        result = get_theme_bootstrap_data()
+
+        # Verify
+        assert result["theme"]["enableUiThemeAdministration"] is True
+        assert "default" in result["theme"]
+        assert "dark" in result["theme"]
+        assert "baseThemeDefault" not in result["theme"]
+        assert "baseThemeDark" not in result["theme"]
+
+    @patch("superset.views.base.app")
+    @patch("superset.views.base.get_config_value")
+    def test_ui_admin_disabled(self, mock_get_config, mock_app):
+        """Test with UI admin disabled, uses config themes"""
+        # Setup
+        mock_app.config = MagicMock()
+        mock_app.config.get.side_effect = lambda k, d=None: {
+            "ENABLE_UI_THEME_ADMINISTRATION": False,
+            "BASE_THEME_DEFAULT": {"token": {"colorPrimary": "#base1"}},
+            "BASE_THEME_DARK": {"token": {"colorPrimary": "#base2"}},
+        }.get(k, d)
+
+        mock_get_config.side_effect = lambda k: {
+            "THEME_DEFAULT": {"token": {"colorPrimary": "#config1"}},
+            "THEME_DARK": {"token": {"colorPrimary": "#config2"}},
+        }.get(k)
+
+        result = get_theme_bootstrap_data()
+
+        # Verify
+        assert result["theme"]["enableUiThemeAdministration"] is False
+        assert result["theme"]["default"] == {"token": {"colorPrimary": 
"#config1"}}
+        assert result["theme"]["dark"] == {"token": {"colorPrimary": 
"#config2"}}
+
+    @patch("superset.views.base.app")
+    @patch("superset.views.base.get_config_value")
+    @patch("superset.views.base.ThemeDAO")
+    def test_ui_admin_enabled_minimal_db_theme(
+        self,
+        mock_dao,
+        mock_get_config,
+        mock_app,
+    ):
+        """Test UI admin with minimal database theme overlaying config theme"""
+        # Setup
+        mock_app.config = MagicMock()
+        mock_app.config.get.side_effect = lambda k, d=None: {
+            "ENABLE_UI_THEME_ADMINISTRATION": True,
+            "BASE_THEME_DEFAULT": {"token": {"colorPrimary": "#base1"}},
+            "BASE_THEME_DARK": {"token": {"colorPrimary": "#base2"}},
+        }.get(k, d)
+
+        # Full config themes with multiple tokens
+        mock_get_config.side_effect = lambda k: {
+            "THEME_DEFAULT": {
+                "token": {
+                    "colorPrimary": "#1890ff",
+                    "colorSuccess": "#52c41a",
+                    "colorWarning": "#faad14",
+                    "fontSize": 14,
+                },
+                "algorithm": "default",
+            },
+            "THEME_DARK": {
+                "token": {
+                    "colorPrimary": "#1890ff",
+                    "colorSuccess": "#52c41a",
+                    "fontSize": 14,
+                },
+                "algorithm": "dark",
+            },
+        }.get(k)
+
+        # Minimal database themes
+        mock_default_theme = MagicMock()
+        mock_default_theme.json_data = '{"token": {"colorPrimary": "#ff00ff"}}'
+        mock_dark_theme = MagicMock()
+        mock_dark_theme.json_data = (
+            '{"token": {"colorWarning": "#orange"}, "algorithm": "dark"}'
+        )
+
+        mock_dao.find_system_default.return_value = mock_default_theme
+        mock_dao.find_system_dark.return_value = mock_dark_theme
+
+        result = get_theme_bootstrap_data()
+
+        # Verify merging behavior
+        assert result["theme"]["enableUiThemeAdministration"] is True
+        # Default theme should merge database with config
+        assert (
+            result["theme"]["default"]["token"]["colorPrimary"] == "#ff00ff"
+        )  # From DB
+        assert (
+            result["theme"]["default"]["token"]["colorSuccess"] == "#52c41a"
+        )  # From config
+        assert (
+            result["theme"]["default"]["token"]["colorWarning"] == "#faad14"
+        )  # From config
+        assert result["theme"]["default"]["token"]["fontSize"] == 14  # From 
config
+        assert result["theme"]["default"]["algorithm"] == "default"  # From 
config
+
+        # Dark theme should merge database with config
+        assert (
+            result["theme"]["dark"]["token"]["colorPrimary"] == "#1890ff"
+        )  # From config
+        assert result["theme"]["dark"]["token"]["colorWarning"] == "#orange"  
# From DB
+        assert result["theme"]["dark"]["algorithm"] == "dark"  # From DB
+
+    @patch("superset.views.base.app")
+    @patch("superset.views.base.get_config_value")
+    @patch("superset.views.base.ThemeDAO")
+    def test_ui_admin_enabled_no_db_themes(
+        self,
+        mock_dao,
+        mock_get_config,
+        mock_app,
+    ):
+        """Test UI admin enabled but no themes in database, falls back to 
config"""
+        # Setup
+        mock_app.config = MagicMock()
+        mock_app.config.get.side_effect = lambda k, d=None: {
+            "ENABLE_UI_THEME_ADMINISTRATION": True,
+            "BASE_THEME_DEFAULT": {"token": {"colorPrimary": "#base1"}},
+            "BASE_THEME_DARK": {"token": {"colorPrimary": "#base2"}},
+        }.get(k, d)
+
+        mock_get_config.side_effect = lambda k: {
+            "THEME_DEFAULT": {"token": {"colorPrimary": "#config1"}},
+            "THEME_DARK": {"token": {"colorPrimary": "#config2"}},
+        }.get(k)
+
+        # No database themes
+        mock_dao.find_system_default.return_value = None
+        mock_dao.find_system_dark.return_value = None
+
+        result = get_theme_bootstrap_data()
+
+        # Should fall back to config themes
+        assert result["theme"]["enableUiThemeAdministration"] is True
+        assert result["theme"]["default"] == {"token": {"colorPrimary": 
"#config1"}}
+        assert result["theme"]["dark"] == {"token": {"colorPrimary": 
"#config2"}}
+
+    @patch("superset.views.base.app")
+    @patch("superset.views.base.get_config_value")
+    @patch("superset.views.base.ThemeDAO")
+    def test_ui_admin_enabled_invalid_db_theme(
+        self,
+        mock_dao,
+        mock_get_config,
+        mock_app,
+    ):
+        """Test UI admin with invalid JSON in database theme"""
+        # Setup
+        mock_app.config = MagicMock()
+        mock_app.config.get.side_effect = lambda k, d=None: {
+            "ENABLE_UI_THEME_ADMINISTRATION": True,
+            "BASE_THEME_DEFAULT": {"token": {"colorPrimary": "#base1"}},
+            "BASE_THEME_DARK": {"token": {"colorPrimary": "#base2"}},
+        }.get(k, d)
+
+        mock_get_config.side_effect = lambda k: {
+            "THEME_DEFAULT": {"token": {"colorPrimary": "#config1"}},
+            "THEME_DARK": {"token": {"colorPrimary": "#config2"}},
+        }.get(k)
+
+        # Invalid JSON in database theme
+        mock_default_theme = MagicMock()
+        mock_default_theme.json_data = "{invalid json"
+        mock_default_theme.id = 1
+
+        mock_dao.find_system_default.return_value = mock_default_theme
+        mock_dao.find_system_dark.return_value = None
+
+        with patch("superset.views.base.logger") as mock_logger:
+            result = get_theme_bootstrap_data()
+
+            # Should fall back to config theme and log error
+            assert result["theme"]["default"] == {"token": {"colorPrimary": 
"#config1"}}
+            mock_logger.error.assert_called_once()
+
+    @patch("superset.views.base.app")
+    @patch("superset.views.base.get_config_value")
+    def test_ui_admin_disabled_no_config_themes(self, mock_get_config, 
mock_app):
+        """Test with UI admin disabled and no config themes (empty themes)"""
+        # Setup
+        mock_app.config = MagicMock()
+        mock_app.config.get.side_effect = lambda k, d=None: {
+            "ENABLE_UI_THEME_ADMINISTRATION": False,
+            "BASE_THEME_DEFAULT": {"token": {"colorPrimary": "#base1"}},
+            "BASE_THEME_DARK": {"token": {"colorPrimary": "#base2"}},
+        }.get(k, d)
+
+        # No config themes (None values)
+        mock_get_config.side_effect = lambda k: None
+
+        result = get_theme_bootstrap_data()
+
+        # Should have empty theme objects
+        assert result["theme"]["enableUiThemeAdministration"] is False
+        assert result["theme"]["default"] == {}
+        assert result["theme"]["dark"] == {}

Reply via email to