fix: apply webapp theme accent in emails and deeplinks

This commit is contained in:
3252a8
2026-06-04 23:22:53 +03:00
parent 090c88603e
commit 77b124d61e
7 changed files with 247 additions and 22 deletions
@@ -8,11 +8,18 @@
<style nonce="__NONCE__"> <style nonce="__NONCE__">
:root { :root {
color-scheme: dark light; color-scheme: dark light;
--accent: #14b86f;
--accent-contrast: #03120b;
--bg: #0b1017;
--panel-3: #344052;
--border: #2d3847;
--text: #f7fafc;
--muted: #aeb8c5;
font-family: font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif; "Segoe UI", sans-serif;
background: #0b1017; background: var(--bg);
color: #f7fafc; color: var(--text);
} }
body { body {
@@ -39,7 +46,7 @@
p { p {
margin: 0; margin: 0;
color: #aeb8c5; color: var(--muted);
font-size: 15px; font-size: 15px;
line-height: 1.55; line-height: 1.55;
} }
@@ -57,8 +64,8 @@
justify-content: center; justify-content: center;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 8px; border-radius: 8px;
background: #14b86f; background: var(--accent);
color: #03120b; color: var(--accent-contrast);
padding: 0 18px; padding: 0 18px;
box-sizing: border-box; box-sizing: border-box;
font: inherit; font: inherit;
@@ -68,15 +75,15 @@
} }
.button.secondary { .button.secondary {
border-color: #2d3847; border-color: var(--border);
background: transparent; background: transparent;
color: #f7fafc; color: var(--text);
} }
.button[aria-disabled="true"] { .button[aria-disabled="true"] {
pointer-events: none; pointer-events: none;
background: #344052; background: var(--panel-3);
color: #aeb8c5; color: var(--muted);
} }
[hidden] { [hidden] {
+62 -4
View File
@@ -5,6 +5,7 @@ import gzip
from config.webapp_themes_config import ( from config.webapp_themes_config import (
default_webapp_theme_asset_file, default_webapp_theme_asset_file,
default_webapp_theme_css_files, default_webapp_theme_css_files,
effective_webapp_theme_accent,
ensure_default_webapp_theme_descriptor_files, ensure_default_webapp_theme_descriptor_files,
public_theme_payload, public_theme_payload,
public_themes_catalog_payload, public_themes_catalog_payload,
@@ -1188,6 +1189,11 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True) nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
query = getattr(request, "query", {}) or {} query = getattr(request, "query", {}) or {}
themes_catalog = getattr(settings, "webapp_themes_catalog", None)
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) or "#00fe7a"
initial_theme = (
_initial_theme_for_request(request, themes_catalog) if themes_catalog is not None else None
)
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru")) lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
messages = _app_deeplink_i18n_payload(request, lang) messages = _app_deeplink_i18n_payload(request, lang)
page_title = _webapp_page_title(settings, messages["title"]) page_title = _webapp_page_title(settings, messages["title"])
@@ -1204,6 +1210,14 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
.replace("__NONCE__", nonce) .replace("__NONCE__", nonce)
.replace("__MESSAGES_JSON__", messages_json) .replace("__MESSAGES_JSON__", messages_json)
) )
initial_theme_markup = _app_deeplink_theme_head_markup(
request,
initial_theme,
themes_catalog,
primary_color,
)
if initial_theme_markup:
html_text = html_text.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url) html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url)
response = web.Response(text=html_text, content_type="text/html", charset="utf-8") response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
@@ -1578,7 +1592,8 @@ def _theme_css_href_for_html(theme: Any) -> str:
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any: def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
preview_key = str(request.query.get("theme_preview") or "").strip() query = getattr(request, "query", {}) or {}
preview_key = str(query.get("theme_preview") or "").strip()
if preview_key: if preview_key:
preview_theme = catalog.theme_by_key(preview_key) preview_theme = catalog.theme_by_key(preview_key)
if preview_theme is not None and preview_theme.enabled: if preview_theme is not None and preview_theme.enabled:
@@ -1590,13 +1605,16 @@ def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
return catalog.enabled_themes()[0] if catalog.enabled_themes() else None return catalog.enabled_themes()[0] if catalog.enabled_themes() else None
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str: def _initial_theme_tokens(theme: Any, primary_color: str) -> Dict[str, Any]:
if theme is None: if theme is None:
return "" return {}
payload = public_theme_payload(theme, primary_color) payload = public_theme_payload(theme, primary_color)
tokens = payload.get("tokens") if isinstance(payload, dict) else {} tokens = payload.get("tokens") if isinstance(payload, dict) else {}
tokens = tokens if isinstance(tokens, dict) else {} return tokens if isinstance(tokens, dict) else {}
def _initial_theme_declarations(tokens: Dict[str, Any]) -> List[str]:
declarations = [] declarations = []
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items(): for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
if token_key in _INITIAL_THEME_LOGO_SCALE_TOKENS: if token_key in _INITIAL_THEME_LOGO_SCALE_TOKENS:
@@ -1610,6 +1628,15 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color:
value = str(tokens.get(token_key) or "").strip() value = str(tokens.get(token_key) or "").strip()
if value: if value:
declarations.append(f"{css_name}:{value}") declarations.append(f"{css_name}:{value}")
return declarations
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
if theme is None:
return ""
tokens = _initial_theme_tokens(theme, primary_color)
declarations = _initial_theme_declarations(tokens)
scheme = "light" if tokens.get("color_scheme") == "light" else "dark" scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
bg = str(tokens.get("bg") or "").strip() bg = str(tokens.get("bg") or "").strip()
@@ -1633,6 +1660,37 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color:
return stylesheet + "\n" + style_tag return stylesheet + "\n" + style_tag
def _app_deeplink_theme_head_markup(
request: web.Request,
theme: Any,
catalog: Any,
primary_color: str,
) -> str:
tokens = _initial_theme_tokens(theme, primary_color)
declarations = _initial_theme_declarations(tokens)
try:
accent = effective_webapp_theme_accent(
catalog,
primary_color,
theme_key=str(getattr(theme, "key", "") or "") or None,
)
except Exception:
accent = str(primary_color or "#00fe7a").strip() or "#00fe7a"
if accent and not any(item.startswith("--accent:") for item in declarations):
declarations.insert(0, f"--accent:{accent}")
if not declarations:
return ""
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
return (
f'<style id="webapp-initial-theme" nonce="{nonce}">'
f"html{{color-scheme:{scheme};}}"
f":root{{{';'.join(declarations)}}}"
"</style>"
)
def _favicon_head_markup(favicon_url: str) -> str: def _favicon_head_markup(favicon_url: str) -> str:
href = str(favicon_url or "").strip() href = str(favicon_url or "").strip()
if not href: if not href:
+27 -7
View File
@@ -16,6 +16,8 @@ from pathlib import Path
from typing import TYPE_CHECKING, Optional, Sequence, Tuple from typing import TYPE_CHECKING, Optional, Sequence, Tuple
from urllib.parse import urlsplit from urllib.parse import urlsplit
from config.webapp_themes_config import effective_webapp_theme_accent
if TYPE_CHECKING: if TYPE_CHECKING:
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from config.settings import Settings from config.settings import Settings
@@ -74,6 +76,17 @@ def _safe_color(value: Optional[str]) -> str:
return _DEFAULT_ACCENT return _DEFAULT_ACCENT
def _theme_accent(settings: Settings) -> str:
primary = _safe_color(getattr(settings, "WEBAPP_PRIMARY_COLOR", None))
try:
catalog = getattr(settings, "webapp_themes_catalog", None)
if catalog is None:
return primary
return _safe_color(effective_webapp_theme_accent(catalog, primary))
except Exception:
return primary
def _public_logo_url(settings: Settings) -> Optional[str]: def _public_logo_url(settings: Settings) -> Optional[str]:
"""Email recipients can't reach the in-app /webapp-logo proxy, so only a """Email recipients can't reach the in-app /webapp-logo proxy, so only a
stored public https URL can be used directly. Anything else is dropped.""" stored public https URL can be used directly. Anything else is dropped."""
@@ -174,8 +187,9 @@ def _layout(
intro_html: str, intro_html: str,
body_html: str, body_html: str,
footer_html: str, footer_html: str,
accent: Optional[str] = None,
) -> _EmailLayout: ) -> _EmailLayout:
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) accent = _safe_color(accent) if accent else _theme_accent(settings)
brand_title = html.escape(_brand_title(settings)) brand_title = html.escape(_brand_title(settings))
logo_url, inline_images = _email_logo(settings) logo_url, inline_images = _email_logo(settings)
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True) html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
@@ -344,7 +358,7 @@ def render_login_code(
i18n = _resolve_i18n(i18n) i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings) lang = _normalize_lang(language_code, settings)
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS) minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) accent = _theme_accent(settings)
brand = _brand_title(settings) brand = _brand_title(settings)
template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code" template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code"
safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else "" safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else ""
@@ -404,6 +418,7 @@ def render_login_code(
intro_html=html.escape(intro), intro_html=html.escape(intro),
body_html=body_html, body_html=body_html,
footer_html=footer, footer_html=footer,
accent=accent,
) )
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
@@ -477,7 +492,7 @@ def render_payment_success(
) -> EmailContent: ) -> EmailContent:
i18n = _resolve_i18n(i18n) i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings) lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) accent = _theme_accent(settings)
brand = _brand_title(settings) brand = _brand_title(settings)
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0] sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
is_traffic = sale_base in { is_traffic = sale_base in {
@@ -591,6 +606,7 @@ def render_payment_success(
intro_html=html.escape(intro), intro_html=html.escape(intro),
body_html="".join(body_parts), body_html="".join(body_parts),
footer_html=footer, footer_html=footer,
accent=accent,
) )
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
@@ -609,7 +625,7 @@ def render_user_notification(
) -> EmailContent: ) -> EmailContent:
i18n = _resolve_i18n(i18n) i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings) lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) accent = _theme_accent(settings)
brand = _brand_title(settings) brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip() safe_dashboard_url = (dashboard_url or "").strip()
final_subject = (subject or "").strip() or _t_text( final_subject = (subject or "").strip() or _t_text(
@@ -642,6 +658,7 @@ def render_user_notification(
intro_html=html.escape(final_intro), intro_html=html.escape(final_intro),
body_html="".join(body_parts), body_html="".join(body_parts),
footer_html=footer, footer_html=footer,
accent=accent,
) )
text_lines = [final_subject, "", _telegram_html_to_text(message_text)] text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
if safe_dashboard_url: if safe_dashboard_url:
@@ -667,7 +684,7 @@ def render_subscription_expiring(
) -> EmailContent: ) -> EmailContent:
i18n = _resolve_i18n(i18n) i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings) lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) accent = _theme_accent(settings)
brand = _brand_title(settings) brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip() safe_dashboard_url = (dashboard_url or "").strip()
days = max(0, int(days_left)) days = max(0, int(days_left))
@@ -716,6 +733,7 @@ def render_subscription_expiring(
intro_html=html.escape(intro), intro_html=html.escape(intro),
body_html="".join(body_parts), body_html="".join(body_parts),
footer_html=footer, footer_html=footer,
accent=accent,
) )
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered) return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
@@ -764,7 +782,7 @@ def render_subscription_lifecycle_notification(
) -> EmailContent: ) -> EmailContent:
i18n = _resolve_i18n(i18n) i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language_code, settings) lang = _normalize_lang(language_code, settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) accent = _theme_accent(settings)
brand = _brand_title(settings) brand = _brand_title(settings)
safe_dashboard_url = (dashboard_url or "").strip() safe_dashboard_url = (dashboard_url or "").strip()
end_date = end_date_text or "" end_date = end_date_text or ""
@@ -804,6 +822,7 @@ def render_subscription_lifecycle_notification(
intro_html=html.escape(intro), intro_html=html.escape(intro),
body_html="".join(body_parts), body_html="".join(body_parts),
footer_html=footer, footer_html=footer,
accent=accent,
) )
text_lines = [subject, "", message_text] text_lines = [subject, "", message_text]
@@ -838,7 +857,7 @@ def _support_email(
i18n = _resolve_i18n(i18n) i18n = _resolve_i18n(i18n)
lang = _normalize_lang(language, settings) lang = _normalize_lang(language, settings)
brand = _brand_title(settings) brand = _brand_title(settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR) accent = _theme_accent(settings)
safe_url = (ticket_url or "").strip() safe_url = (ticket_url or "").strip()
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
localized_rows = [ localized_rows = [
@@ -861,6 +880,7 @@ def _support_email(
intro_html=html.escape(intro), intro_html=html.escape(intro),
body_html="".join(body_parts), body_html="".join(body_parts),
footer_html=footer, footer_html=footer,
accent=accent,
) )
text_lines = [ text_lines = [
intro, intro,
+32
View File
@@ -619,6 +619,38 @@ def merge_primary_accent_into_theme_tokens(
return base return base
def effective_webapp_theme_accent(
config: WebappThemesConfig,
primary_accent: str,
*,
theme_key: Optional[str] = None,
) -> str:
"""Return the accent color users see for the selected/default Web App theme."""
try:
fallback = ThemeTokens(accent=primary_accent or "#00fe7a").accent or "#00fe7a"
except ValueError:
fallback = "#00fe7a"
theme: Optional[WebappTheme] = None
if theme_key:
theme = config.theme_by_key(theme_key)
if theme is not None and not theme.enabled:
theme = None
if theme is None:
theme = config.theme_by_key(config.default_theme)
if theme is None:
enabled = config.enabled_themes()
theme = enabled[0] if enabled else None
if theme is None:
return fallback
tokens = (
merge_primary_accent_into_theme_tokens(theme, fallback)
if theme.use_primary_accent
else theme.tokens
)
return tokens.accent or fallback
def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]: def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]:
tokens = ( tokens = (
merge_primary_accent_into_theme_tokens(theme, primary_accent) merge_primary_accent_into_theme_tokens(theme, primary_accent)
+37 -2
View File
@@ -20,6 +20,7 @@ from bot.services.email_templates import (
render_support_user_reply_admin, render_support_user_reply_admin,
render_user_notification, render_user_notification,
) )
from config.webapp_themes_config import WebappThemesConfig
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
EMAIL_KEY_RE = re.compile(r"""["'](?P<key>email_[a-z0-9_]+)["']""") EMAIL_KEY_RE = re.compile(r"""["'](?P<key>email_[a-z0-9_]+)["']""")
@@ -31,13 +32,19 @@ EMAIL_KEY_ASSIGNMENT_HINTS = (
) )
def _settings(default_language: str = "ru"): def _settings(
default_language: str = "ru",
*,
webapp_themes_catalog: WebappThemesConfig | None = None,
primary_color: str = "#00fe7a",
):
return SimpleNamespace( return SimpleNamespace(
DEFAULT_LANGUAGE=default_language, DEFAULT_LANGUAGE=default_language,
EMAIL_CODE_TTL_SECONDS=600, EMAIL_CODE_TTL_SECONDS=600,
WEBAPP_LOGO_URL="", WEBAPP_LOGO_URL="",
WEBAPP_PRIMARY_COLOR="#00fe7a", WEBAPP_PRIMARY_COLOR=primary_color,
WEBAPP_TITLE="Mini Shop", WEBAPP_TITLE="Mini Shop",
webapp_themes_catalog=webapp_themes_catalog,
) )
@@ -256,6 +263,34 @@ def test_all_email_template_variants_render_without_raw_locale_keys():
_assert_content_is_localized(content, language) _assert_content_is_localized(content, language)
def test_email_templates_use_default_webapp_theme_accent():
catalog = WebappThemesConfig(
default_theme="custom",
themes=[
{
"key": "custom",
"enabled": True,
"default": True,
"tokens": {"color_scheme": "dark", "accent": "#123abc"},
}
],
)
settings = _settings("en", webapp_themes_catalog=catalog, primary_color="#00fe7a")
content = render_login_code(
settings,
code="123456",
language_code="en",
magic_link="https://app.example.com/magic",
purpose="login",
i18n=_i18n("en"),
)
assert "color:#123abc" in content.html
assert 'bgcolor="#123abc"' in content.html
assert "#00fe7a" not in content.html
def test_uploaded_webapp_logo_is_embedded_inline(tmp_path, monkeypatch): def test_uploaded_webapp_logo_is_embedded_inline(tmp_path, monkeypatch):
uploads_dir = tmp_path / "uploads" uploads_dir = tmp_path / "uploads"
uploads_dir.mkdir() uploads_dir.mkdir()
+34
View File
@@ -11,6 +11,7 @@ from aiohttp.test_utils import make_mocked_request
from bot.app.web import admin_api, subscription_webapp from bot.app.web import admin_api, subscription_webapp
from bot.app.web.admin_api_impl import auth as admin_auth_routes from bot.app.web.admin_api_impl import auth as admin_auth_routes
from bot.app.web.webapp_auth import create_webapp_session_token from bot.app.web.webapp_auth import create_webapp_session_token
from config.webapp_themes_config import WebappThemesConfig
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -332,6 +333,39 @@ class WebAppRouteContractTests(unittest.TestCase):
(REPO_ROOT / "backend/bot/app/web/templates/open_app_gateway.html").is_file() (REPO_ROOT / "backend/bot/app/web/templates/open_app_gateway.html").is_file()
) )
def test_app_deeplink_gateway_uses_webapp_theme_accent(self):
catalog = WebappThemesConfig(
default_theme="custom",
themes=[
{
"key": "custom",
"enabled": True,
"default": True,
"tokens": {"color_scheme": "dark", "accent": "#123abc"},
}
],
)
request = _Request(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_TITLE="/minishop",
DEFAULT_LANGUAGE="en",
WEBAPP_PRIMARY_COLOR="#00fe7a",
webapp_themes_catalog=catalog,
)
}
)
request["csp_nonce"] = "nonce-value"
response = asyncio.run(subscription_webapp.app_deeplink_route(request))
self.assertEqual(response.status, 200)
self.assertIn('id="webapp-initial-theme"', response.text)
self.assertIn("--accent:#123abc", response.text)
self.assertIn("background: var(--accent)", response.text)
self.assertNotIn("background: #14b86f;", response.text)
def test_app_launch_i18n_keys_are_available_to_webapp_bootstrap(self): def test_app_launch_i18n_keys_are_available_to_webapp_bootstrap(self):
required_keys = { required_keys = {
"wa_app_launch_title", "wa_app_launch_title",
+39
View File
@@ -8,6 +8,7 @@ from config.webapp_themes_config import (
apply_webapp_theme_env_overrides, apply_webapp_theme_env_overrides,
builtin_webapp_themes_config, builtin_webapp_themes_config,
default_webapp_theme_descriptors, default_webapp_theme_descriptors,
effective_webapp_theme_accent,
ensure_webapp_core_themes, ensure_webapp_core_themes,
load_webapp_theme_dir, load_webapp_theme_dir,
public_themes_catalog_payload, public_themes_catalog_payload,
@@ -281,6 +282,44 @@ class WebappThemesConfigTests(unittest.TestCase):
self.assertTrue(win95["use_in_admin"]) self.assertTrue(win95["use_in_admin"])
self.assertNotIn("accent", win95["tokens"]) self.assertNotIn("accent", win95["tokens"])
def test_effective_accent_uses_default_theme_token(self):
cfg = WebappThemesConfig(
default_theme="custom",
themes=[
{
"key": "custom",
"enabled": True,
"default": True,
"tokens": {"color_scheme": "dark", "accent": "#123abc"},
}
],
)
self.assertEqual(effective_webapp_theme_accent(cfg, "#00fe7a"), "#123abc")
def test_effective_accent_can_use_preview_theme_token(self):
cfg = WebappThemesConfig(
default_theme="dark",
themes=[
{
"key": "dark",
"enabled": True,
"default": True,
"tokens": {"color_scheme": "dark", "accent": "#123abc"},
},
{
"key": "neon",
"enabled": True,
"tokens": {"color_scheme": "dark", "accent": "#ff33aa"},
},
],
)
self.assertEqual(
effective_webapp_theme_accent(cfg, "#00fe7a", theme_key="neon"),
"#ff33aa",
)
def test_theme_accent_is_normalized_to_hex(self): def test_theme_accent_is_normalized_to_hex(self):
cfg = WebappThemesConfig( cfg = WebappThemesConfig(
default_theme="custom", default_theme="custom",