Compare commits

...

1 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
68b04fcf71 fix(themes): serve system themes with the algorithm of the slot they fill
A theme picked as the system dark theme keeps its own algorithm when merged
onto the config dark base, so a light theme in the dark slot was served with
algorithm "default". Ant Design then derived a mix of light and dark tokens
and dark mode rendered as a half-themed UI.

Force each theme to carry the algorithm of the slot it fills, and warn admins
in the confirmation dialog when the theme they pick contradicts the slot.
2026-08-03 11:43:10 +03:00
8 changed files with 304 additions and 9 deletions

View File

@@ -0,0 +1,46 @@
/**
* 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.
*/
import { hasConflictingAlgorithm } from './utils';
test('flags a light theme assigned to the dark slot', () => {
expect(hasConflictingAlgorithm('{"algorithm": "default"}', true)).toBe(true);
expect(
hasConflictingAlgorithm('{"algorithm": ["default", "compact"]}', true),
).toBe(true);
});
test('flags a dark theme assigned to the light slot', () => {
expect(hasConflictingAlgorithm('{"algorithm": "dark"}', false)).toBe(true);
});
test('does not flag a theme matching its slot', () => {
expect(hasConflictingAlgorithm('{"algorithm": "dark"}', true)).toBe(false);
expect(hasConflictingAlgorithm('{"algorithm": "default"}', false)).toBe(
false,
);
expect(
hasConflictingAlgorithm('{"algorithm": ["dark", "compact"]}', true),
).toBe(false);
});
test('does not flag a theme without a usable algorithm', () => {
expect(hasConflictingAlgorithm(undefined, true)).toBe(false);
expect(hasConflictingAlgorithm('{"token": {}}', true)).toBe(false);
expect(hasConflictingAlgorithm('not json', true)).toBe(false);
});

View File

@@ -0,0 +1,48 @@
/**
* 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.
*/
import { ThemeAlgorithm } from '@apache-superset/core/theme';
const getThemeAlgorithms = (jsonData?: string): string[] => {
if (!jsonData) return [];
try {
const { algorithm } = JSON.parse(jsonData) ?? {};
if (typeof algorithm === 'string') return [algorithm];
if (Array.isArray(algorithm))
return algorithm.filter(alg => typeof alg === 'string');
return [];
} catch {
return [];
}
};
/**
* Whether a theme declares an algorithm that contradicts the system slot it is
* about to fill. Such a theme is still served with the slot's algorithm, so the
* colors it was authored with will not be the ones users see.
*/
export const hasConflictingAlgorithm = (
jsonData: string | undefined,
isDarkSlot: boolean,
): boolean => {
const algorithms = getThemeAlgorithms(jsonData);
if (!algorithms.length) return false;
return algorithms.includes(ThemeAlgorithm.DARK) !== isDarkSlot;
};

View File

@@ -103,7 +103,7 @@ const mockThemes = [
is_system_default: false,
is_system_dark: false,
is_system: false,
json_data: '{"colors": {"primary": "#52c41a"}}',
json_data: '{"algorithm": "default", "colors": {"primary": "#52c41a"}}',
created_by: { id: 2, first_name: 'Test', last_name: 'User' },
changed_on_delta_humanized: '3 days ago',
changed_by: {
@@ -360,6 +360,32 @@ test('shows set dark action for non-dark themes', async () => {
expect(setDarkButtons.length).toBe(2);
});
test('warns when setting a light theme as the system dark theme', async () => {
render(
<ThemesList
user={mockUser}
addDangerToast={jest.fn()}
addSuccessToast={jest.fn()}
/>,
{
useRedux: true,
useRouter: true,
useQueryParams: true,
useTheme: true,
},
);
await screen.findByText('Custom Theme');
// Custom Theme declares the light algorithm; Light Theme declares none
const setDarkButtons = await screen.findAllByTestId('set-dark-action');
await userEvent.click(setDarkButtons[1]);
expect(
await screen.findByText(/This theme uses the light algorithm/),
).toBeInTheDocument();
});
test('shows unset dark action for system dark theme', async () => {
render(
<ThemesList

View File

@@ -52,6 +52,7 @@ import {
import ThemeModal from 'src/features/themes/ThemeModal';
import { ThemeObject } from 'src/features/themes/types';
import { hasConflictingAlgorithm } from 'src/features/themes/utils';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { Icons } from '@superset-ui/core/components/Icons';
import { useConfirmModal } from 'src/hooks/useConfirmModal';
@@ -273,9 +274,22 @@ function ThemesList({
(theme: ThemeObject) => {
showConfirm({
title: t('Set System Default Theme'),
body: 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,
body: (
<Space direction="vertical">
{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,
)}
{hasConflictingAlgorithm(theme.json_data, false) && (
<Alert
type="warning"
showIcon
message={t(
'This theme uses the dark algorithm. It will be rendered with the light algorithm instead, so its colors may not look as designed.',
)}
/>
)}
</Space>
),
onConfirm: async () => {
try {
@@ -299,9 +313,22 @@ function ThemesList({
(theme: ThemeObject) => {
showConfirm({
title: t('Set System Dark Theme'),
body: 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,
body: (
<Space direction="vertical">
{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,
)}
{hasConflictingAlgorithm(theme.json_data, true) && (
<Alert
type="warning"
showIcon
message={t(
'This theme uses the light algorithm. It will be rendered with the dark algorithm instead, so its colors may not look as designed.',
)}
/>
)}
</Space>
),
onConfirm: async () => {
try {

View File

@@ -14,15 +14,21 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any, Dict
from typing import Any, cast, Dict
from urllib.parse import urlparse
from flask import current_app
from marshmallow import ValidationError
from superset.themes.types import ThemeMode
from superset.themes.types import Theme, ThemeMode
from superset.utils.core import sanitize_svg_content, sanitize_url
# Algorithm names Ant Design actually understands; ThemeMode also carries
# "system", which is a mode rather than a mapping algorithm.
ANTD_ALGORITHMS = frozenset(
{ThemeMode.DEFAULT.value, ThemeMode.DARK.value, ThemeMode.COMPACT.value}
)
def _is_valid_theme_mode(mode: str) -> bool:
"""Validate if a string represents a valid theme mode.
@@ -52,6 +58,59 @@ def _is_valid_algorithm(algorithm: Any) -> bool:
return False
def _algorithm_list(algorithm: Any) -> list[str]:
"""Coerce a theme algorithm field into a list of Ant Design algorithm names."""
if isinstance(algorithm, str):
candidates = [algorithm]
elif isinstance(algorithm, list):
candidates = [alg for alg in algorithm if isinstance(alg, str)]
else:
candidates = []
return [alg for alg in candidates if alg in ANTD_ALGORITHMS]
def enforce_theme_algorithm(theme: Theme, mode: ThemeMode) -> Theme:
"""Force a theme's algorithm to match the slot it is served in.
Admins pick which theme fills the system default (light) and system dark
slots, and nothing stops them from picking a theme whose own algorithm
contradicts the slot. Ant Design then derives a mix of light and dark
tokens, which renders as a broken half-themed UI, so the algorithm is
rewritten to match the slot while preserving modifiers such as ``compact``.
Args:
theme: Theme configuration to normalize
mode: The slot the theme is served in (``DEFAULT`` or ``DARK``)
Returns:
Theme: Theme with a slot-consistent algorithm
"""
if not theme:
return theme
wanted = ThemeMode.DARK.value if mode == ThemeMode.DARK else ThemeMode.DEFAULT.value
conflicting = (
ThemeMode.DEFAULT.value if mode == ThemeMode.DARK else ThemeMode.DARK.value
)
algorithms = _algorithm_list(theme.get("algorithm"))
if wanted in algorithms and conflicting not in algorithms:
return theme
corrected = [wanted] + [
alg for alg in algorithms if alg not in (wanted, conflicting)
]
return cast(
Theme,
{
**theme,
"algorithm": corrected[0] if len(corrected) == 1 else corrected,
},
)
def is_valid_theme(theme: Dict[str, Any]) -> bool:
"""Validate theme dictionary structure and types.

View File

@@ -64,6 +64,7 @@ from superset.reports.models import ReportRecipientType
from superset.superset_typing import FlaskResponse
from superset.themes.types import Theme, ThemeMode
from superset.themes.utils import (
enforce_theme_algorithm,
is_valid_theme,
)
from superset.translations.utils import get_language_pack
@@ -428,6 +429,11 @@ def get_theme_bootstrap_data() -> dict[str, Any]:
default_theme = _process_theme(default_theme, ThemeMode.DEFAULT)
dark_theme = _process_theme(dark_theme, ThemeMode.DARK)
# Force each theme to carry the algorithm of the slot it fills, so a light
# theme assigned to the dark slot (or vice versa) still renders consistently
default_theme = enforce_theme_algorithm(default_theme, ThemeMode.DEFAULT)
dark_theme = enforce_theme_algorithm(dark_theme, ThemeMode.DARK)
return {
"theme": {
"default": default_theme,

View File

@@ -24,12 +24,54 @@ from superset.themes.types import ThemeMode
from superset.themes.utils import (
_is_valid_algorithm,
_is_valid_theme_mode,
enforce_theme_algorithm,
is_valid_theme,
sanitize_theme_tokens,
validate_font_urls,
)
@pytest.mark.parametrize(
"algorithm, mode, expected",
[
# A theme authored for light mode must not stay light in the dark slot
("default", ThemeMode.DARK, "dark"),
(["default", "compact"], ThemeMode.DARK, ["dark", "compact"]),
# ...and vice versa
("dark", ThemeMode.DEFAULT, "default"),
(["dark", "compact"], ThemeMode.DEFAULT, ["default", "compact"]),
# A missing or unusable algorithm is filled in from the slot
(None, ThemeMode.DARK, "dark"),
(None, ThemeMode.DEFAULT, "default"),
("system", ThemeMode.DARK, "dark"),
("compact", ThemeMode.DARK, ["dark", "compact"]),
# Matching algorithms are left untouched
("dark", ThemeMode.DARK, "dark"),
(["dark", "compact"], ThemeMode.DARK, ["dark", "compact"]),
("default", ThemeMode.DEFAULT, "default"),
],
)
def test_enforce_theme_algorithm(algorithm, mode, expected):
"""Test that a theme is served with the algorithm of the slot it fills."""
theme = {"token": {"colorPrimary": "#000"}}
if algorithm is not None:
theme["algorithm"] = algorithm
result = enforce_theme_algorithm(theme, mode)
assert result["algorithm"] == expected
assert result["token"] == {"colorPrimary": "#000"}
def test_enforce_theme_algorithm_keeps_empty_theme_empty():
"""Test that an empty theme is not given an algorithm.
An empty theme signals "no custom theme" to the frontend, so it must stay
empty rather than becoming a theme carrying only an algorithm.
"""
assert enforce_theme_algorithm({}, ThemeMode.DARK) == {}
@pytest.mark.parametrize(
"mode, expected",
[

View File

@@ -532,6 +532,47 @@ class TestGetThemeBootstrapData:
assert result["theme"]["default"] == {}
assert result["theme"]["dark"] == {}
@patch("superset.views.base.app")
@patch("superset.views.base.get_config_value")
@patch("superset.views.base.ThemeDAO")
def test_light_theme_in_dark_slot_gets_dark_algorithm(
self,
mock_dao,
mock_get_config,
mock_app,
):
"""Regression test for a light theme selected as the system dark theme.
The database theme's algorithm wins the merge against the config base,
so without correction the dark slot would be served ``default`` and Ant
Design would derive a mix of light and dark tokens.
"""
mock_app.config = MagicMock()
mock_app.config.get.side_effect = lambda k, d=None: {
"ENABLE_UI_THEME_ADMINISTRATION": True,
}.get(k, d)
mock_get_config.side_effect = lambda k: {
"THEME_DEFAULT": {"token": {"colorPrimary": "#config1"}},
"THEME_DARK": {"token": {"colorPrimary": "#config2"}},
}.get(k)
# The same light theme is set as both the system default and system dark
light_theme = MagicMock()
light_theme.json_data = (
'{"token": {"colorPrimary": "#db1"}, "algorithm": "default"}'
)
mock_dao.find_system_default.return_value = light_theme
mock_dao.find_system_dark.return_value = light_theme
result = get_theme_bootstrap_data()
assert result["theme"]["default"]["algorithm"] == "default"
assert result["theme"]["dark"]["algorithm"] == "dark"
# The theme's own tokens are still honored in both slots
assert result["theme"]["dark"]["token"]["colorPrimary"] == "#db1"
@patch("superset.views.base.app")
@patch("superset.views.base.get_config_value")
def test_partial_theme_override_preserves_base_tokens(