From 452b294da0d4f0d233ace6ebc33b44fa65d8655a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 12:28:47 -0400 Subject: [PATCH 1/5] feat(theme): support light and dark brand colors --- CHANGELOG.md | 5 ++ shiny/ui/_theme_brand.py | 116 ++++++++++++++++++++++++++++++- tests/pytest/test_theme.py | 138 +++++++++++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ec3469d2..b50a3f6553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Improvements +* `ui.Theme.from_brand()` now supports light/dark brand colors and typography + colors. The generated theme emits mode-scoped Bootstrap/Shiny CSS variables + that respond to the existing `data-bs-theme` attribute without recompiling + the theme. + * Navsets created with an `id` (e.g. `ui.navset_tab(id="tabs")`) now use that `id` as their `data-tabsetid`, so their tab panes get stable `tab-tabs-0` style DOM ids instead of ones built from a random integer. This makes the rendered markup reproducible across renders and easier to target from custom CSS and JavaScript. Navsets without an `id`, and `ui.nav_menu()` dropdowns, keep the random ID. (Thanks, @pevolution-ahmed!) (#2410) ### Bug fixes diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 7ddca99a2d..031f82cfa4 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -2,7 +2,7 @@ import warnings from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Union +from typing import TYPE_CHECKING, Any, Optional, TypeGuard, Union if TYPE_CHECKING: from brand_yml import Brand @@ -177,6 +177,27 @@ def join_str(x: str | None, y: str | None): class ThemeBrand(Theme): + _COLOR_BOOTSTRAP_VARIABLES: dict[str, tuple[str, ...]] = { + "foreground": ("body-color",), + "background": ("body-bg",), + "primary": ("primary",), + "secondary": ("secondary", "secondary-color"), + "tertiary": ("tertiary-color",), + "success": ("success",), + "info": ("info",), + "warning": ("warning",), + "danger": ("danger",), + "light": ("light",), + "dark": ("dark",), + "link": ("link-color",), + } + _TYPOGRAPHY_BOOTSTRAP_VARIABLES: dict[tuple[str, str], tuple[str, ...]] = { + ("headings", "color"): ("heading-color",), + ("link", "color"): ("link-color",), + ("link", "background_color"): ("link-bg",), + ("monospace_inline", "color"): ("code-color",), + } + def __init__( self, brand: "Brand", @@ -224,6 +245,7 @@ def __init__( # Rules ---- self.add_rules(*brand_color_palette_rules) + self.add_rules(ThemeBrand._prepare_css_vars(brand)) # Bootstrap extras: functions, mixins, rules (defaults handled above) self._add_brand_bootstrap_other(brand_bootstrap) @@ -255,12 +277,16 @@ def _prepare_color_vars( # ==> $primary: $brand_color_primary !default; brand_color_var = f"brand_color_{thm_name}" - defaults_dict[brand_color_var] = thm_color + if ThemeBrand._is_sass_scalar(thm_color): + defaults_dict[brand_color_var] = thm_color brand_color_palette = brand.color.to_dict(include="palette") # Map the brand color palette to Bootstrap's named colors, e.g. $red, $blue. for pal_name, pal_color in brand_color_palette.items(): + if not ThemeBrand._is_sass_scalar(pal_color): + continue + if pal_name in bootstrap_colors: defaults_dict[pal_name] = pal_color @@ -311,10 +337,94 @@ def _prepare_typography_vars(brand: "Brand") -> dict[str, YamlScalarType]: for field, prop in brand_typography.items(): for prop_key, prop_value in prop.items(): typo_sass_var = f"brand_typography_{field}_{prop_key}" - mapped[typo_sass_var] = prop_value + if ThemeBrand._is_sass_scalar(prop_value): + mapped[typo_sass_var] = prop_value return mapped + @staticmethod + def _is_sass_scalar(value: Any) -> TypeGuard[YamlScalarType]: + return value is None or isinstance(value, (bool, float, int, str)) + + @staticmethod + def _is_color_defined(value: Any, mode: str) -> bool: + if isinstance(value, str): + return True + + return getattr(value, mode, None) is not None + + @staticmethod + def _prepare_css_vars(brand: "Brand") -> str: + """ + Emit brand variables and their Bootstrap/Shiny aliases. + + The aliases must be omitted when a partial light/dark value does not + define the current mode. An omitted alias leaves the Bootstrap value + from the base theme in effect. + """ + brand_css = brand.css_variables( + { + "light": '[data-bs-theme="light"]', + "dark": '[data-bs-theme="dark"]', + } + ) + mapping_css = ThemeBrand._prepare_css_mappings(brand) + return "\n".join([brand_css, mapping_css]) + + @staticmethod + def _prepare_css_mappings(brand: "Brand") -> str: + rules: list[str] = [] + + for mode in ("light", "dark"): + declarations: list[str] = [] + + if brand.color is not None: + for ( + color_name, + bootstrap_names, + ) in ThemeBrand._COLOR_BOOTSTRAP_VARIABLES.items(): + value = getattr(brand.color, color_name, None) + if value is None or not ThemeBrand._is_color_defined(value, mode): + continue + + brand_variable = f"--brand-color-{color_name.replace('_', '-')}" + declarations.extend( + f" --bs-{bootstrap_name}: var({brand_variable});" + for bootstrap_name in bootstrap_names + ) + + if brand.typography is not None: + for ( + field, + property_name, + ), bootstrap_names in ( + ThemeBrand._TYPOGRAPHY_BOOTSTRAP_VARIABLES.items() + ): + typography_node = getattr(brand.typography, field, None) + value = getattr(typography_node, property_name, None) + if value is None or not ThemeBrand._is_color_defined(value, mode): + continue + + brand_variable = ( + f"--brand-typography-{field.replace('_', '-')}-" + f"{property_name.replace('_', '-')}" + ) + declarations.extend( + f" --bs-{bootstrap_name}: var({brand_variable});" + for bootstrap_name in bootstrap_names + ) + + if declarations: + rules.extend( + [ + f'[data-bs-theme="{mode}"] {{', + *declarations, + "}", + ] + ) + + return "\n".join(rules) + def _add_defaults_hdr(self, header: str, **kwargs: YamlScalarType): self.add_defaults(**kwargs) self.add_defaults(f"\n// *---- brand: {header} ----* //") diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index 129de13b45..d8872a05c9 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -1,3 +1,4 @@ +import re import tempfile from typing import Callable, Optional @@ -286,3 +287,140 @@ def test_theme_from_brand_base_case_compiles(preset: str): # Check that the CSS compiles without error css = theme.to_css() assert isinstance(css, str) + + +@skip_on_windows +def test_theme_from_brand_light_dark_colors_emit_mode_scoped_variables(): + brand_txt = """ +color: + foreground: + light: "#111111" + dark: "#eeeeee" + background: + light: "#ffffff" + dark: "#222222" + primary: + light: "#0066cc" + dark: "#66b2ff" + link: + light: "#0055aa" + dark: "#99ccff" +typography: + headings: + color: foreground + monospace-inline: + color: foreground + link: + color: link + background-color: + light: "#eef6ff" + dark: "#203040" +""" + + with tempfile.TemporaryDirectory() as tmpdir: + with open(f"{tmpdir}/_brand.yml", "w") as f: + f.write(brand_txt) + + theme = Theme.from_brand(tmpdir) + css = theme.to_css() + + assert '[data-bs-theme="light"]' in css + assert '[data-bs-theme="dark"]' in css + assert "--brand-color-foreground: #111111" in css + assert "--brand-color-foreground: #eeeeee" in css + assert "--bs-body-color: var(--brand-color-foreground)" in css + assert "--bs-body-bg: var(--brand-color-background)" in css + assert "--bs-primary: var(--brand-color-primary)" in css + assert "--bs-link-color: var(--brand-color-link)" in css + assert "--bs-heading-color: var(--brand-typography-headings-color)" in css + assert "--bs-code-color: var(--brand-typography-monospace-inline-color)" in css + assert "--bs-link-bg: var(--brand-typography-link-background-color)" in css + + +@skip_on_windows +def test_theme_from_brand_partial_color_omits_missing_mode_mapping(): + brand_txt = """ +color: + background: + dark: "#222222" + primary: + light: "#0066cc" +typography: + headings: + color: background +""" + + with tempfile.TemporaryDirectory() as tmpdir: + with open(f"{tmpdir}/_brand.yml", "w") as f: + f.write(brand_txt) + + theme = Theme.from_brand(tmpdir) + css = theme.to_css() + + assert "--brand-color-background: #222222" in css + assert "--bs-body-bg: var(--brand-color-background)" in css + assert css.count("--brand-color-background: #222222") == 1 + assert css.count("--bs-body-bg: var(--brand-color-background)") == 1 + assert "--brand-color-primary: #0066cc" in css + assert "--bs-primary: var(--brand-color-primary)" in css + + mode_blocks = re.findall(r'\[data-bs-theme="(light|dark)"\]\{([^}]*)\}', css)[-4:] + assert [mode for mode, _ in mode_blocks] == ["light", "dark", "light", "dark"] + light_custom = mode_blocks[0][1] + dark_custom = mode_blocks[1][1] + light_mapping = mode_blocks[2][1] + dark_mapping = mode_blocks[3][1] + + assert "--brand-color-background" not in light_custom + assert "--brand-typography-headings-color" not in light_custom + assert "--bs-body-bg: var(--brand-color-background)" not in light_custom + assert "--brand-color-primary: #0066cc" in light_custom + assert "--bs-primary: var(--brand-color-primary)" in light_mapping + assert "--bs-heading-color" not in light_mapping + assert "--brand-color-primary" not in dark_custom + assert "--brand-typography-headings-color: #222222" in dark_custom + assert "--brand-color-primary" not in dark_mapping + assert "--bs-heading-color: var(--brand-typography-headings-color)" in dark_mapping + + +@skip_on_windows +def test_theme_from_brand_scalar_values_are_emitted_in_both_modes(): + brand_txt = """ +color: + primary: "#0066cc" +typography: + headings: + color: "#333333" +""" + + with tempfile.TemporaryDirectory() as tmpdir: + with open(f"{tmpdir}/_brand.yml", "w") as f: + f.write(brand_txt) + + theme = Theme.from_brand(tmpdir) + css = theme.to_css() + + assert css.count("--brand-color-primary: #0066cc") == 2 + assert css.count("--bs-primary: var(--brand-color-primary)") == 2 + assert css.count("--brand-typography-headings-color: #333333") == 2 + assert css.count("--bs-heading-color: var(--brand-typography-headings-color)") == 2 + + +@skip_on_windows +def test_theme_from_brand_retains_variant_values_for_runtime_switching(): + brand_txt = """ +color: + primary: + light: "#0066cc" + dark: "#66b2ff" +""" + + with tempfile.TemporaryDirectory() as tmpdir: + with open(f"{tmpdir}/_brand.yml", "w") as f: + f.write(brand_txt) + + theme = Theme.from_brand(tmpdir) + + assert theme.brand.color.primary.light == "#0066cc" + assert theme.brand.color.primary.dark == "#66b2ff" + assert all("brand_color_primary: {" not in default for default in theme._defaults) From b44ae103afc993af0e17c7ee56f22b378aafe437 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 12:33:59 -0400 Subject: [PATCH 2/5] fix(theme): apply mode-specific code colors --- shiny/ui/_theme_brand.py | 38 ++++++++++++++ tests/pytest/test_theme.py | 103 ++++++++++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 031f82cfa4..df09a3a0d3 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -196,6 +196,9 @@ class ThemeBrand(Theme): ("link", "color"): ("link-color",), ("link", "background_color"): ("link-bg",), ("monospace_inline", "color"): ("code-color",), + ("monospace_inline", "background_color"): ("code-bg",), + ("monospace_block", "color"): ("pre-color",), + ("monospace_block", "background_color"): ("pre-bg",), } def __init__( @@ -377,6 +380,8 @@ def _prepare_css_mappings(brand: "Brand") -> str: for mode in ("light", "dark"): declarations: list[str] = [] + inline_code_rules: list[str] = [] + block_code_rules: list[str] = [] if brand.color is not None: for ( @@ -414,6 +419,21 @@ def _prepare_css_mappings(brand: "Brand") -> str: for bootstrap_name in bootstrap_names ) + if field == "monospace_inline": + if property_name == "color": + inline_code_rules.append(" color: var(--bs-code-color);") + elif property_name == "background_color": + inline_code_rules.append( + " background-color: var(--bs-code-bg);" + ) + elif field == "monospace_block": + if property_name == "color": + block_code_rules.append(" color: var(--bs-pre-color);") + elif property_name == "background_color": + block_code_rules.append( + " background-color: var(--bs-pre-bg);" + ) + if declarations: rules.extend( [ @@ -423,6 +443,24 @@ def _prepare_css_mappings(brand: "Brand") -> str: ] ) + if inline_code_rules: + rules.extend( + [ + f'[data-bs-theme="{mode}"] code:not(pre > code) {{', + *inline_code_rules, + "}", + ] + ) + + if block_code_rules: + rules.extend( + [ + f'[data-bs-theme="{mode}"] pre {{', + *block_code_rules, + "}", + ] + ) + return "\n".join(rules) def _add_defaults_hdr(self, header: str, **kwargs: YamlScalarType): diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index d8872a05c9..dd89b7399c 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -107,6 +107,12 @@ def test_theme_css_compiles_and_is_cached(preset: ShinyThemePreset): assert second_css.find(".MY_MIXIN") != -1 +def _css_rule_body(css: str, selector: str) -> str: + match = re.search(rf"{re.escape(selector)}\{{([^}}]*)\}}", css) + assert match is not None + return match.group(1) + + def test_theme_update_preset(): theme = Theme("shiny") assert theme._preset == "shiny" @@ -310,6 +316,14 @@ def test_theme_from_brand_light_dark_colors_emit_mode_scoped_variables(): color: foreground monospace-inline: color: foreground + background-color: + light: "#f1f5fa" + dark: "#263746" + monospace-block: + color: foreground + background-color: + light: "#f8f9fa" + dark: "#1f2933" link: color: link background-color: @@ -334,7 +348,30 @@ def test_theme_from_brand_light_dark_colors_emit_mode_scoped_variables(): assert "--bs-link-color: var(--brand-color-link)" in css assert "--bs-heading-color: var(--brand-typography-headings-color)" in css assert "--bs-code-color: var(--brand-typography-monospace-inline-color)" in css + assert ( + "--bs-code-bg: var(--brand-typography-monospace-inline-background-color)" in css + ) + assert "--bs-pre-color: var(--brand-typography-monospace-block-color)" in css + assert ( + "--bs-pre-bg: var(--brand-typography-monospace-block-background-color)" in css + ) assert "--bs-link-bg: var(--brand-typography-link-background-color)" in css + assert ( + "color:var(--bs-code-color);background-color:var(--bs-code-bg)" + in _css_rule_body(css, '[data-bs-theme="light"] code:not(pre>code)') + ) + assert ( + "color:var(--bs-code-color);background-color:var(--bs-code-bg)" + in _css_rule_body(css, '[data-bs-theme="dark"] code:not(pre>code)') + ) + assert ( + "color:var(--bs-pre-color);background-color:var(--bs-pre-bg)" + in _css_rule_body(css, '[data-bs-theme="light"] pre') + ) + assert ( + "color:var(--bs-pre-color);background-color:var(--bs-pre-bg)" + in _css_rule_body(css, '[data-bs-theme="dark"] pre') + ) @skip_on_windows @@ -348,6 +385,14 @@ def test_theme_from_brand_partial_color_omits_missing_mode_mapping(): typography: headings: color: background + monospace-inline: + background-color: + dark: "#303030" + monospace-block: + color: + light: "#202020" + background-color: + dark: "#181818" """ with tempfile.TemporaryDirectory() as tmpdir: @@ -364,7 +409,13 @@ def test_theme_from_brand_partial_color_omits_missing_mode_mapping(): assert "--brand-color-primary: #0066cc" in css assert "--bs-primary: var(--brand-color-primary)" in css - mode_blocks = re.findall(r'\[data-bs-theme="(light|dark)"\]\{([^}]*)\}', css)[-4:] + mode_blocks = [ + (mode, body) + for mode, body in re.findall( + r'\[data-bs-theme="(light|dark)"\]\{([^}]*)\}', css + ) + if "--brand-" in body + ] assert [mode for mode, _ in mode_blocks] == ["light", "dark", "light", "dark"] light_custom = mode_blocks[0][1] dark_custom = mode_blocks[1][1] @@ -381,6 +432,38 @@ def test_theme_from_brand_partial_color_omits_missing_mode_mapping(): assert "--brand-typography-headings-color: #222222" in dark_custom assert "--brand-color-primary" not in dark_mapping assert "--bs-heading-color: var(--brand-typography-headings-color)" in dark_mapping + assert "--brand-typography-monospace-inline-background-color" not in light_custom + assert "--brand-typography-monospace-block-color" in light_custom + assert "--brand-typography-monospace-block-background-color" not in light_custom + assert ( + "--brand-typography-monospace-inline-background-color: #303030" in dark_custom + ) + assert "--brand-typography-monospace-block-background-color: #181818" in dark_custom + assert ( + "--bs-code-bg: var(--brand-typography-monospace-inline-background-color)" + in dark_mapping + ) + assert ( + "--bs-pre-bg: var(--brand-typography-monospace-block-background-color)" + in dark_mapping + ) + assert ( + "--bs-pre-color: var(--brand-typography-monospace-block-color)" + not in dark_mapping + ) + assert '[data-bs-theme="light"] code:not(pre>code)' not in css + assert ( + _css_rule_body(css, '[data-bs-theme="dark"] code:not(pre>code)') + == "background-color:var(--bs-code-bg)" + ) + assert ( + _css_rule_body(css, '[data-bs-theme="light"] pre') + == "color:var(--bs-pre-color)" + ) + assert ( + _css_rule_body(css, '[data-bs-theme="dark"] pre') + == "background-color:var(--bs-pre-bg)" + ) @skip_on_windows @@ -391,6 +474,12 @@ def test_theme_from_brand_scalar_values_are_emitted_in_both_modes(): typography: headings: color: "#333333" + monospace-inline: + color: "#111111" + background-color: "#f1f5fa" + monospace-block: + color: "#222222" + background-color: "#f8f9fa" """ with tempfile.TemporaryDirectory() as tmpdir: @@ -404,6 +493,18 @@ def test_theme_from_brand_scalar_values_are_emitted_in_both_modes(): assert css.count("--bs-primary: var(--brand-color-primary)") == 2 assert css.count("--brand-typography-headings-color: #333333") == 2 assert css.count("--bs-heading-color: var(--brand-typography-headings-color)") == 2 + assert css.count("--brand-typography-monospace-inline-color: #111111") == 2 + assert ( + css.count("--brand-typography-monospace-inline-background-color: #f1f5fa") == 2 + ) + assert css.count("--brand-typography-monospace-block-color: #222222") == 2 + assert ( + css.count("--brand-typography-monospace-block-background-color: #f8f9fa") == 2 + ) + assert css.count('[data-bs-theme="light"] code:not(pre>code)') == 1 + assert css.count('[data-bs-theme="dark"] code:not(pre>code)') == 1 + assert css.count('[data-bs-theme="light"] pre') == 1 + assert css.count('[data-bs-theme="dark"] pre') == 1 @skip_on_windows From 820f2ba14d58e4dcf40d28afaada3ba26e0d96ca Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 12:37:59 -0400 Subject: [PATCH 3/5] fix(tests): narrow brand color assertions --- tests/pytest/test_theme.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index dd89b7399c..aeb6bf8390 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -522,6 +522,10 @@ def test_theme_from_brand_retains_variant_values_for_runtime_switching(): theme = Theme.from_brand(tmpdir) - assert theme.brand.color.primary.light == "#0066cc" - assert theme.brand.color.primary.dark == "#66b2ff" + assert theme.brand.color is not None + primary = theme.brand.color.primary + assert primary is not None + assert not isinstance(primary, str) + assert primary.light == "#0066cc" + assert primary.dark == "#66b2ff" assert all("brand_color_primary: {" not in default for default in theme._defaults) From ac3f630fed2909acf21bdeda2656fcfbe3f82dc9 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 13:29:04 -0400 Subject: [PATCH 4/5] fix(theme): Complete light and dark brand modes --- CHANGELOG.md | 6 +- pyproject.toml | 4 +- .../shiny-for-python/references/theming.md | 18 ++ shiny/ui/_theme_brand.py | 249 +++++++++-------- shiny/www/py-shiny/brand/_brand-yml.scss | 211 ++++++++++++++ .../shiny/brand_color_modes/_brand.yml | 38 +++ .../playwright/shiny/brand_color_modes/app.py | 17 ++ .../test_brand_color_modes.py | 101 +++++++ tests/pytest/test_theme.py | 263 ++++++++++++------ 9 files changed, 692 insertions(+), 215 deletions(-) create mode 100644 tests/playwright/shiny/brand_color_modes/_brand.yml create mode 100644 tests/playwright/shiny/brand_color_modes/app.py create mode 100644 tests/playwright/shiny/brand_color_modes/test_brand_color_modes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b50a3f6553..8945c577df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Improvements * `ui.Theme.from_brand()` now supports light/dark brand colors and typography - colors. The generated theme emits mode-scoped Bootstrap/Shiny CSS variables - that respond to the existing `data-bs-theme` attribute without recompiling - the theme. + colors. The generated theme applies light colors to ordinary pages and emits + complete mode-scoped Bootstrap/Shiny color tokens and component styles that + respond to the existing `data-bs-theme` attribute without recompiling. * Navsets created with an `id` (e.g. `ui.navset_tab(id="tabs")`) now use that `id` as their `data-tabsetid`, so their tab panes get stable `tab-tabs-0` style DOM ids instead of ones built from a random integer. This makes the rendered markup reproducible across renders and easier to target from custom CSS and JavaScript. Navsets without an `id`, and `ui.nav_menu()` dropdowns, keep the random ID. (Thanks, @pevolution-ahmed!) (#2410) diff --git a/pyproject.toml b/pyproject.toml index 314ddb5267..262fded59f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ dependencies = [ ] [project.optional-dependencies] -theme = ["libsass>=0.23.0", "brand_yml>=0.2.0"] +theme = ["libsass>=0.23.0", "brand_yml>=0.2.1"] otel = [ # 1.24.0+ required: same sanitized-stack-trace floor as opentelemetry-api above "opentelemetry-sdk>=1.24.0", @@ -151,7 +151,7 @@ dev = [ "Flake8-pyproject>=1.2.3", "isort>=5.10.1", "libsass>=0.23.0", - "brand_yml>=0.2.0", + "brand_yml>=0.2.1", "pyrefly>=1.1.1", "pyright>=1.1.407", "pre-commit>=2.15.0", diff --git a/shiny/.agents/skills/shiny-for-python/references/theming.md b/shiny/.agents/skills/shiny-for-python/references/theming.md index 22da6ea940..58062e8651 100644 --- a/shiny/.agents/skills/shiny-for-python/references/theming.md +++ b/shiny/.agents/skills/shiny-for-python/references/theming.md @@ -133,6 +133,24 @@ from shiny.express import ui ui.page_opts(theme=ui.Theme.from_brand(__file__)) ``` +Theme and typography colors may use `light` and `dark` variants. Light values +are the default when the page has no explicit color-mode attribute, and +`ui.input_dark_mode()` switches the same compiled theme at runtime: + +```yaml +color: + foreground: { light: "#17212b", dark: "#edf2f7" } + background: { light: "#ffffff", dark: "#17212b" } + primary: { light: "#0066cc", dark: "#66b2ff" } +typography: + link: + color: { light: "#0055aa", dark: "#99ccff" } +``` + +A variant may define only one mode. In that case Shiny preserves Bootstrap's +theme value in the undefined mode rather than copying the defined color across +modes. Scalar colors continue to apply in both modes. + For authoring the `_brand.yml` file itself, use the external `shiny:brand-yml` skill — do not hand-write the spec here. diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index df09a3a0d3..acfaa94556 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -177,29 +177,16 @@ def join_str(x: str | None, y: str | None): class ThemeBrand(Theme): - _COLOR_BOOTSTRAP_VARIABLES: dict[str, tuple[str, ...]] = { - "foreground": ("body-color",), - "background": ("body-bg",), - "primary": ("primary",), - "secondary": ("secondary", "secondary-color"), - "tertiary": ("tertiary-color",), - "success": ("success",), - "info": ("info",), - "warning": ("warning",), - "danger": ("danger",), - "light": ("light",), - "dark": ("dark",), - "link": ("link-color",), - } - _TYPOGRAPHY_BOOTSTRAP_VARIABLES: dict[tuple[str, str], tuple[str, ...]] = { - ("headings", "color"): ("heading-color",), - ("link", "color"): ("link-color",), - ("link", "background_color"): ("link-bg",), - ("monospace_inline", "color"): ("code-color",), - ("monospace_inline", "background_color"): ("code-bg",), - ("monospace_block", "color"): ("pre-color",), - ("monospace_block", "background_color"): ("pre-bg",), - } + _THEME_COLOR_NAMES = ( + "primary", + "secondary", + "success", + "info", + "warning", + "danger", + "light", + "dark", + ) def __init__( self, @@ -350,118 +337,140 @@ def _is_sass_scalar(value: Any) -> TypeGuard[YamlScalarType]: return value is None or isinstance(value, (bool, float, int, str)) @staticmethod - def _is_color_defined(value: Any, mode: str) -> bool: + def _color_value(value: Any, mode: str) -> str | None: if isinstance(value, str): - return True + return value - return getattr(value, mode, None) is not None + mode_value = getattr(value, mode, None) + return mode_value if isinstance(mode_value, str) else None @staticmethod def _prepare_css_vars(brand: "Brand") -> str: """ - Emit brand variables and their Bootstrap/Shiny aliases. + Emit brand variables and complete Bootstrap color-mode layers. - The aliases must be omitted when a partial light/dark value does not - define the current mode. An omitted alias leaves the Bootstrap value - from the base theme in effect. + Light colors apply to an ordinary document without a ``data-bs-theme`` + attribute as well as to explicit light mode. Undefined branches are + omitted so Bootstrap's compiled value remains in effect. """ brand_css = brand.css_variables( { - "light": '[data-bs-theme="light"]', + "light": 'html:not([data-bs-theme]), [data-bs-theme="light"]', "dark": '[data-bs-theme="dark"]', } ) - mapping_css = ThemeBrand._prepare_css_mappings(brand) - return "\n".join([brand_css, mapping_css]) + mode_rules = [ + ThemeBrand._prepare_color_mode(brand, mode) for mode in ("light", "dark") + ] + return "\n".join([brand_css, *mode_rules]) + + @staticmethod + def _typography_color_value( + brand: "Brand", field: str, property_name: str, mode: str + ) -> tuple[bool, str | None]: + if brand.typography is None: + return False, None + + typography_node = getattr(brand.typography, field, None) + if typography_node is None: + return False, None + + value = getattr(typography_node, property_name, None) + return value is not None, ThemeBrand._color_value(value, mode) @staticmethod - def _prepare_css_mappings(brand: "Brand") -> str: - rules: list[str] = [] - - for mode in ("light", "dark"): - declarations: list[str] = [] - inline_code_rules: list[str] = [] - block_code_rules: list[str] = [] - - if brand.color is not None: - for ( - color_name, - bootstrap_names, - ) in ThemeBrand._COLOR_BOOTSTRAP_VARIABLES.items(): - value = getattr(brand.color, color_name, None) - if value is None or not ThemeBrand._is_color_defined(value, mode): - continue - - brand_variable = f"--brand-color-{color_name.replace('_', '-')}" - declarations.extend( - f" --bs-{bootstrap_name}: var({brand_variable});" - for bootstrap_name in bootstrap_names - ) - - if brand.typography is not None: - for ( - field, - property_name, - ), bootstrap_names in ( - ThemeBrand._TYPOGRAPHY_BOOTSTRAP_VARIABLES.items() - ): - typography_node = getattr(brand.typography, field, None) - value = getattr(typography_node, property_name, None) - if value is None or not ThemeBrand._is_color_defined(value, mode): - continue - - brand_variable = ( - f"--brand-typography-{field.replace('_', '-')}-" - f"{property_name.replace('_', '-')}" - ) - declarations.extend( - f" --bs-{bootstrap_name}: var({brand_variable});" - for bootstrap_name in bootstrap_names - ) - - if field == "monospace_inline": - if property_name == "color": - inline_code_rules.append(" color: var(--bs-code-color);") - elif property_name == "background_color": - inline_code_rules.append( - " background-color: var(--bs-code-bg);" - ) - elif field == "monospace_block": - if property_name == "color": - block_code_rules.append(" color: var(--bs-pre-color);") - elif property_name == "background_color": - block_code_rules.append( - " background-color: var(--bs-pre-bg);" - ) - - if declarations: - rules.extend( - [ - f'[data-bs-theme="{mode}"] {{', - *declarations, - "}", - ] - ) - - if inline_code_rules: - rules.extend( - [ - f'[data-bs-theme="{mode}"] code:not(pre > code) {{', - *inline_code_rules, - "}", - ] - ) - - if block_code_rules: - rules.extend( - [ - f'[data-bs-theme="{mode}"] pre {{', - *block_code_rules, - "}", - ] - ) - - return "\n".join(rules) + def _prepare_color_mode(brand: "Brand", mode: str) -> str: + color = brand.color + theme_colors = { + name: ThemeBrand._color_value(getattr(color, name, None), mode) + for name in ThemeBrand._THEME_COLOR_NAMES + if color is not None + } + theme_colors = { + name: value for name, value in theme_colors.items() if value is not None + } + + def brand_color(name: str) -> str | None: + if color is None: + return None + return ThemeBrand._color_value(getattr(color, name, None), mode) + + headings_configured, headings = ThemeBrand._typography_color_value( + brand, "headings", "color", mode + ) + link_configured, link = ThemeBrand._typography_color_value( + brand, "link", "color", mode + ) + link_background_configured, link_background = ( + ThemeBrand._typography_color_value(brand, "link", "background_color", mode) + ) + code_configured, code = ThemeBrand._typography_color_value( + brand, "monospace_inline", "color", mode + ) + code_background_configured, code_background = ( + ThemeBrand._typography_color_value( + brand, "monospace_inline", "background_color", mode + ) + ) + pre_configured, pre = ThemeBrand._typography_color_value( + brand, "monospace_block", "color", mode + ) + pre_background_configured, pre_background = ThemeBrand._typography_color_value( + brand, "monospace_block", "background_color", mode + ) + + color_link = getattr(color, "link", None) if color is not None else None + if not link_configured: + if color_link is not None: + link = ThemeBrand._color_value(color_link, mode) + else: + link = theme_colors.get("primary") + + values = { + "foreground": brand_color("foreground"), + "background": brand_color("background"), + "secondary": brand_color("secondary"), + "tertiary": brand_color("tertiary"), + "headings": headings if headings_configured else None, + "link": link, + "link-background": ( + link_background if link_background_configured else None + ), + "code": code if code_configured else None, + "code-background": ( + code_background if code_background_configured else None + ), + "pre": pre if pre_configured else None, + "pre-background": pre_background if pre_background_configured else None, + } + + if not theme_colors and not any(value is not None for value in values.values()): + return "" + + selectors = ( + '("html:not([data-bs-theme])", \'[data-bs-theme="light"]\')' + if mode == "light" + else "('[data-bs-theme=\"dark\"]',)" + ) + theme_color_map = ["("] + theme_color_map.extend( + f' "{name}": {value},' for name, value in theme_colors.items() + ) + theme_color_map.append(" ),") + + arguments = [ + "@include brand-color-mode(", + f" {selectors},", + f" {mode},", + *theme_color_map, + ] + arguments.extend( + f" ${name}: {value}," + for name, value in values.items() + if value is not None + ) + arguments.append(");") + return "\n".join(arguments) def _add_defaults_hdr(self, header: str, **kwargs: YamlScalarType): self.add_defaults(**kwargs) diff --git a/shiny/www/py-shiny/brand/_brand-yml.scss b/shiny/www/py-shiny/brand/_brand-yml.scss index de6a1f6f41..e30f5f3443 100644 --- a/shiny/www/py-shiny/brand/_brand-yml.scss +++ b/shiny/www/py-shiny/brand/_brand-yml.scss @@ -176,6 +176,217 @@ $code-block-line-height: null !default; $link-bg: null !default; $link-weight: null !default; +/*-- scss:mixins --*/ + +@mixin brand-button-variant( + $background, + $border, + $color: color-contrast($background), + $hover-background: if($color == $color-contrast-light, shade-color($background, $btn-hover-bg-shade-amount), tint-color($background, $btn-hover-bg-tint-amount)), + $hover-border: if($color == $color-contrast-light, shade-color($border, $btn-hover-border-shade-amount), tint-color($border, $btn-hover-border-tint-amount)), + $hover-color: color-contrast($hover-background), + $active-background: if($color == $color-contrast-light, shade-color($background, $btn-active-bg-shade-amount), tint-color($background, $btn-active-bg-tint-amount)), + $active-border: if($color == $color-contrast-light, shade-color($border, $btn-active-border-shade-amount), tint-color($border, $btn-active-border-tint-amount)), + $active-color: color-contrast($active-background), + $disabled-background: $background, + $disabled-border: $border, + $disabled-color: color-contrast($disabled-background) +) { + --#{$prefix}btn-color: #{$color}; + --#{$prefix}btn-bg: #{$background}; + --#{$prefix}btn-border-color: #{$border}; + --#{$prefix}btn-hover-color: #{$hover-color}; + --#{$prefix}btn-hover-bg: #{$hover-background}; + --#{$prefix}btn-hover-border-color: #{$hover-border}; + --#{$prefix}btn-focus-shadow-rgb: #{to-rgb(mix($color, $border, 15%))}; + --#{$prefix}btn-active-color: #{$active-color}; + --#{$prefix}btn-active-bg: #{$active-background}; + --#{$prefix}btn-active-border-color: #{$active-border}; + --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow}; + --#{$prefix}btn-disabled-color: #{$disabled-color}; + --#{$prefix}btn-disabled-bg: #{$disabled-background}; + --#{$prefix}btn-disabled-border-color: #{$disabled-border}; +} + +@mixin brand-button-outline-variant( + $color, + $color-hover: color-contrast($color), + $active-background: $color, + $active-border: $color, + $active-color: color-contrast($active-background) +) { + --#{$prefix}btn-color: #{$color}; + --#{$prefix}btn-border-color: #{$color}; + --#{$prefix}btn-hover-color: #{$color-hover}; + --#{$prefix}btn-hover-bg: #{$active-background}; + --#{$prefix}btn-hover-border-color: #{$active-border}; + --#{$prefix}btn-focus-shadow-rgb: #{to-rgb($color)}; + --#{$prefix}btn-active-color: #{$active-color}; + --#{$prefix}btn-active-bg: #{$active-background}; + --#{$prefix}btn-active-border-color: #{$active-border}; + --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow}; + --#{$prefix}btn-disabled-color: #{$color}; + --#{$prefix}btn-disabled-bg: transparent; + --#{$prefix}btn-disabled-border-color: #{$color}; + --#{$prefix}btn-bg: transparent; + --#{$prefix}gradient: none; +} + +@mixin brand-color-mode( + $selectors, + $mode, + $brand-theme-colors: (), + $foreground: null, + $background: null, + $secondary: null, + $tertiary: null, + $headings: null, + $link: null, + $link-background: null, + $code: null, + $code-background: null, + $pre: null, + $pre-background: null +) { + @each $selector in $selectors { + #{$selector} { + @if $foreground != null { + --#{$prefix}body-color: #{$foreground}; + --#{$prefix}body-color-rgb: #{to-rgb($foreground)}; + } + @if $background != null { + --#{$prefix}body-bg: #{$background}; + --#{$prefix}body-bg-rgb: #{to-rgb($background)}; + } + @if $secondary != null { + --#{$prefix}secondary-color: #{$secondary}; + --#{$prefix}secondary-color-rgb: #{to-rgb($secondary)}; + } + @if $tertiary != null { + --#{$prefix}tertiary-color: #{$tertiary}; + --#{$prefix}tertiary-color-rgb: #{to-rgb($tertiary)}; + } + @if $headings != null { + --#{$prefix}heading-color: #{$headings}; + } + @if $link != null { + $link-hover: shift-color( + $link, + if($mode == dark, -$link-shade-percentage, $link-shade-percentage) + ); + --#{$prefix}link-color: #{$link}; + --#{$prefix}link-color-rgb: #{to-rgb($link)}; + --#{$prefix}link-hover-color: #{$link-hover}; + --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover)}; + } + @if $link-background != null { + --#{$prefix}link-bg: #{$link-background}; + } + @if $code != null { + --#{$prefix}code-color: #{$code}; + } + @if $code-background != null { + --#{$prefix}code-bg: #{$code-background}; + } + @if $pre != null { + --#{$prefix}pre-color: #{$pre}; + } + @if $pre-background != null { + --#{$prefix}pre-bg: #{$pre-background}; + } + + @each $name, $value in $brand-theme-colors { + --#{$prefix}#{$name}: #{$value}; + --#{$prefix}#{$name}-rgb: #{to-rgb($value)}; + + @if $mode == dark { + --#{$prefix}#{$name}-text-emphasis: #{tint-color($value, 40%)}; + --#{$prefix}#{$name}-bg-subtle: #{shade-color($value, 80%)}; + --#{$prefix}#{$name}-border-subtle: #{shade-color($value, 40%)}; + } @else { + --#{$prefix}#{$name}-text-emphasis: #{shade-color($value, 60%)}; + --#{$prefix}#{$name}-bg-subtle: #{tint-color($value, 80%)}; + --#{$prefix}#{$name}-border-subtle: #{tint-color($value, 60%)}; + } + + @if $name == "primary" { + --#{$prefix}focus-ring-color: #{rgba($value, $focus-ring-opacity)}; + } + + .btn-#{$name} { + @if $name == "light" { + @include brand-button-variant( + $value, + $value, + $hover-background: shade-color($value, $btn-hover-bg-shade-amount), + $hover-border: shade-color($value, $btn-hover-border-shade-amount), + $active-background: shade-color($value, $btn-active-bg-shade-amount), + $active-border: shade-color($value, $btn-active-border-shade-amount) + ); + } @else if $name == "dark" { + @include brand-button-variant( + $value, + $value, + $hover-background: tint-color($value, $btn-hover-bg-tint-amount), + $hover-border: tint-color($value, $btn-hover-border-tint-amount), + $active-background: tint-color($value, $btn-active-bg-tint-amount), + $active-border: tint-color($value, $btn-active-border-tint-amount) + ); + } @else { + @include brand-button-variant($value, $value); + } + } + + .btn-outline-#{$name} { + @include brand-button-outline-variant($value); + } + + .bg-#{$name}, + .text-bg-#{$name} { + color: color-contrast($value) if($enable-important-utilities, !important, null); + } + + .link-#{$name}:hover, + .link-#{$name}:focus { + $hover-color: if( + color-contrast($value) == $color-contrast-light, + shade-color($value, $link-shade-percentage), + tint-color($value, $link-shade-percentage) + ); + color: RGBA(#{to-rgb($hover-color)}, var(--#{$prefix}link-opacity, 1)) if($enable-important-utilities, !important, null); + text-decoration-color: RGBA(#{to-rgb($hover-color)}, var(--#{$prefix}link-underline-opacity, 1)) if($enable-important-utilities, !important, null); + } + } + + @if $link-background != null { + a { + background-color: var(--#{$prefix}link-bg); + } + } + @if $code != null or $code-background != null { + code:not(pre > code) { + @if $code != null { + color: var(--#{$prefix}code-color); + } + @if $code-background != null { + background-color: var(--#{$prefix}code-bg); + } + } + } + @if $pre != null or $pre-background != null { + pre { + @if $pre != null { + color: var(--#{$prefix}pre-color); + } + @if $pre-background != null { + background-color: var(--#{$prefix}pre-bg); + } + } + } + } + } +} + /*-- scss:rules --*/ // *---- brand: brand rules to augment Bootstrap rules ----* // diff --git a/tests/playwright/shiny/brand_color_modes/_brand.yml b/tests/playwright/shiny/brand_color_modes/_brand.yml new file mode 100644 index 0000000000..aefcdc6cc6 --- /dev/null +++ b/tests/playwright/shiny/brand_color_modes/_brand.yml @@ -0,0 +1,38 @@ +color: + foreground: + light: "#111111" + dark: "#eeeeee" + background: + light: "#ffffff" + dark: "#222222" + primary: + light: "#cc0000" + dark: "#00aa44" + success: "#8844cc" + warning: + dark: "#ff8800" + link: + light: "#0055aa" + dark: "#99ccff" +typography: + headings: + color: foreground + link: + background-color: + light: "#eef6ff" + dark: "#203040" + monospace-inline: + color: foreground + background-color: + light: "#f1f5fa" + dark: "#263746" + monospace-block: + color: foreground + background-color: + light: "#f8f9fa" + dark: "#1f2933" +defaults: + bootstrap: + preset: bootstrap + defaults: + prefix: acme- diff --git a/tests/playwright/shiny/brand_color_modes/app.py b/tests/playwright/shiny/brand_color_modes/app.py new file mode 100644 index 0000000000..7cba03e4f8 --- /dev/null +++ b/tests/playwright/shiny/brand_color_modes/app.py @@ -0,0 +1,17 @@ +from shiny import App, ui + +theme = ui.Theme.from_brand(__file__) + +app_ui = ui.page_fluid( + ui.h2("Brand color modes", id="heading"), + ui.tags.a("Brand link", id="link", href="#"), + ui.tags.button("Primary", id="primary", class_="btn btn-primary"), + ui.tags.div("Primary background", id="primary-bg", class_="bg-primary"), + ui.tags.div("Scalar success", id="success-bg", class_="bg-success"), + ui.tags.div("Partial warning", id="warning-bg", class_="bg-warning"), + ui.tags.code("Inline code", id="inline-code"), + ui.tags.pre("Block code", id="block-code"), + theme=theme, +) + +app = App(app_ui, None) diff --git a/tests/playwright/shiny/brand_color_modes/test_brand_color_modes.py b/tests/playwright/shiny/brand_color_modes/test_brand_color_modes.py new file mode 100644 index 0000000000..6fc06dfbd7 --- /dev/null +++ b/tests/playwright/shiny/brand_color_modes/test_brand_color_modes.py @@ -0,0 +1,101 @@ +from playwright.sync_api import Page, expect + +from shiny.run import ShinyAppProc + + +def _root_variable(page: Page, name: str) -> str: + return page.locator("html").evaluate( + "(element, property) => getComputedStyle(element)" + ".getPropertyValue(property).trim()", + name, + ) + + +def test_default_document_uses_light_brand_colors( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + html = page.locator("html") + body = page.locator("body") + primary = page.locator("#primary") + primary_bg = page.locator("#primary-bg") + success_bg = page.locator("#success-bg") + warning_bg = page.locator("#warning-bg") + link = page.locator("#link") + heading = page.locator("#heading") + inline_code = page.locator("#inline-code") + block_code = page.locator("#block-code") + + expect(html).not_to_have_attribute("data-bs-theme", "light") + expect(body).to_have_css("color", "rgb(17, 17, 17)") + expect(body).to_have_css("background-color", "rgb(255, 255, 255)") + expect(primary).to_have_css("background-color", "rgb(204, 0, 0)") + expect(primary_bg).to_have_css("background-color", "rgb(204, 0, 0)") + expect(success_bg).to_have_css("background-color", "rgb(136, 68, 204)") + expect(warning_bg).to_have_css("background-color", "rgb(255, 193, 7)") + expect(link).to_have_css("color", "rgb(0, 85, 170)") + expect(link).to_have_css("background-color", "rgb(238, 246, 255)") + expect(heading).to_have_css("color", "rgb(17, 17, 17)") + expect(inline_code).to_have_css("color", "rgb(17, 17, 17)") + expect(inline_code).to_have_css("background-color", "rgb(241, 245, 250)") + expect(block_code).to_have_css("color", "rgb(17, 17, 17)") + expect(block_code).to_have_css("background-color", "rgb(248, 249, 250)") + assert _root_variable(page, "--acme-primary-rgb") == "204,0,0" + + +def test_explicit_dark_mode_uses_dark_and_scalar_brand_colors( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + html = page.locator("html") + html.evaluate("element => element.setAttribute('data-bs-theme', 'dark')") + + body = page.locator("body") + primary = page.locator("#primary") + primary_bg = page.locator("#primary-bg") + success_bg = page.locator("#success-bg") + warning_bg = page.locator("#warning-bg") + link = page.locator("#link") + heading = page.locator("#heading") + inline_code = page.locator("#inline-code") + block_code = page.locator("#block-code") + + expect(body).to_have_css("color", "rgb(238, 238, 238)") + expect(body).to_have_css("background-color", "rgb(34, 34, 34)") + expect(primary).to_have_css("background-color", "rgb(0, 170, 68)") + expect(primary_bg).to_have_css("background-color", "rgb(0, 170, 68)") + expect(success_bg).to_have_css("background-color", "rgb(136, 68, 204)") + expect(warning_bg).to_have_css("background-color", "rgb(255, 136, 0)") + expect(link).to_have_css("color", "rgb(153, 204, 255)") + expect(link).to_have_css("background-color", "rgb(32, 48, 64)") + expect(heading).to_have_css("color", "rgb(238, 238, 238)") + expect(inline_code).to_have_css("color", "rgb(238, 238, 238)") + expect(inline_code).to_have_css("background-color", "rgb(38, 55, 70)") + expect(block_code).to_have_css("color", "rgb(238, 238, 238)") + expect(block_code).to_have_css("background-color", "rgb(31, 41, 51)") + assert _root_variable(page, "--acme-primary-rgb") == "0,170,68" + + +def test_runtime_mode_switch_updates_derived_components( + page: Page, local_app: ShinyAppProc +) -> None: + page.goto(local_app.url) + + html = page.locator("html") + body = page.locator("body") + primary = page.locator("#primary") + primary_bg = page.locator("#primary-bg") + + expect(primary).to_have_css("background-color", "rgb(204, 0, 0)") + + html.evaluate("element => element.setAttribute('data-bs-theme', 'dark')") + expect(body).to_have_css("color", "rgb(238, 238, 238)") + expect(primary).to_have_css("background-color", "rgb(0, 170, 68)") + expect(primary_bg).to_have_css("background-color", "rgb(0, 170, 68)") + + html.evaluate("element => element.setAttribute('data-bs-theme', 'light')") + expect(body).to_have_css("color", "rgb(17, 17, 17)") + expect(primary).to_have_css("background-color", "rgb(204, 0, 0)") + expect(primary_bg).to_have_css("background-color", "rgb(204, 0, 0)") diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index aeb6bf8390..4cef9af504 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -108,9 +108,9 @@ def test_theme_css_compiles_and_is_cached(preset: ShinyThemePreset): def _css_rule_body(css: str, selector: str) -> str: - match = re.search(rf"{re.escape(selector)}\{{([^}}]*)\}}", css) - assert match is not None - return match.group(1) + matches = re.findall(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", css) + assert matches + return re.sub(r"\s+", "", matches[-1]) def test_theme_update_preset(): @@ -291,12 +291,12 @@ def test_theme_from_brand_base_case_compiles(preset: str): assert any(["brand: brand rules" in r for r in theme._rules]) # Check that the CSS compiles without error - css = theme.to_css() + css = theme.to_css({"output_style": "expanded"}) assert isinstance(css, str) @skip_on_windows -def test_theme_from_brand_light_dark_colors_emit_mode_scoped_variables(): +def test_theme_from_brand_light_dark_colors_emit_complete_mode_layers(): brand_txt = """ color: foreground: @@ -336,41 +336,66 @@ def test_theme_from_brand_light_dark_colors_emit_mode_scoped_variables(): f.write(brand_txt) theme = Theme.from_brand(tmpdir) - css = theme.to_css() + css = theme.to_css({"output_style": "expanded"}) assert '[data-bs-theme="light"]' in css assert '[data-bs-theme="dark"]' in css assert "--brand-color-foreground: #111111" in css assert "--brand-color-foreground: #eeeeee" in css - assert "--bs-body-color: var(--brand-color-foreground)" in css - assert "--bs-body-bg: var(--brand-color-background)" in css - assert "--bs-primary: var(--brand-color-primary)" in css - assert "--bs-link-color: var(--brand-color-link)" in css - assert "--bs-heading-color: var(--brand-typography-headings-color)" in css - assert "--bs-code-color: var(--brand-typography-monospace-inline-color)" in css - assert ( - "--bs-code-bg: var(--brand-typography-monospace-inline-background-color)" in css - ) - assert "--bs-pre-color: var(--brand-typography-monospace-block-color)" in css - assert ( - "--bs-pre-bg: var(--brand-typography-monospace-block-background-color)" in css - ) - assert "--bs-link-bg: var(--brand-typography-link-background-color)" in css - assert ( - "color:var(--bs-code-color);background-color:var(--bs-code-bg)" - in _css_rule_body(css, '[data-bs-theme="light"] code:not(pre>code)') + + light_root = _css_rule_body(css, "html:not([data-bs-theme])") + explicit_light_root = _css_rule_body(css, '[data-bs-theme="light"]') + dark_root = _css_rule_body(css, '[data-bs-theme="dark"]') + + for root, expected in ( + (light_root, ("#111111", "17,17,17", "#ffffff", "255,255,255")), + ( + explicit_light_root, + ("#111111", "17,17,17", "#ffffff", "255,255,255"), + ), + (dark_root, ("#eeeeee", "238,238,238", "#222222", "34,34,34")), + ): + foreground, foreground_rgb, background, background_rgb = expected + assert f"--bs-body-color:{foreground}" in root + assert f"--bs-body-color-rgb:{foreground_rgb}" in root + assert f"--bs-body-bg:{background}" in root + assert f"--bs-body-bg-rgb:{background_rgb}" in root + + assert "--bs-primary:#0066cc" in light_root + assert "--bs-primary-rgb:0,102,204" in light_root + assert "--bs-link-color:#0055aa" in light_root + assert "--bs-link-color-rgb:0,85,170" in light_root + assert "--bs-heading-color:#111111" in light_root + assert "--bs-code-color:#111111" in light_root + assert "--bs-code-bg:#f1f5fa" in light_root + assert "--bs-pre-color:#111111" in light_root + assert "--bs-pre-bg:#f8f9fa" in light_root + assert "--bs-link-bg:#eef6ff" in light_root + + assert "--bs-primary:#66b2ff" in dark_root + assert "--bs-primary-rgb:102,178,255" in dark_root + assert "--bs-link-color:#99ccff" in dark_root + assert "--bs-link-color-rgb:153,204,255" in dark_root + assert "--bs-heading-color:#eeeeee" in dark_root + + light_button = _css_rule_body(css, "html:not([data-bs-theme]) .btn-primary") + dark_button = _css_rule_body(css, '[data-bs-theme="dark"] .btn-primary') + assert "--bs-btn-bg:#0066cc" in light_button + assert "--bs-btn-border-color:#0066cc" in light_button + assert "--bs-btn-bg:#66b2ff" in dark_button + assert "--bs-btn-border-color:#66b2ff" in dark_button + + assert "color:var(--bs-code-color)" in _css_rule_body( + css, "html:not([data-bs-theme]) code:not(pre > code)" ) - assert ( - "color:var(--bs-code-color);background-color:var(--bs-code-bg)" - in _css_rule_body(css, '[data-bs-theme="dark"] code:not(pre>code)') + assert "background-color:var(--bs-code-bg)" in _css_rule_body( + css, '[data-bs-theme="dark"] code:not(pre > code)' ) - assert ( - "color:var(--bs-pre-color);background-color:var(--bs-pre-bg)" - in _css_rule_body(css, '[data-bs-theme="light"] pre') + assert "color:var(--bs-pre-color)" in _css_rule_body( + css, "html:not([data-bs-theme]) pre" ) - assert ( - "color:var(--bs-pre-color);background-color:var(--bs-pre-bg)" - in _css_rule_body(css, '[data-bs-theme="dark"] pre') + assert "background-color:var(--bs-pre-bg)" in _css_rule_body( + css, '[data-bs-theme="dark"] pre' ) @@ -382,6 +407,8 @@ def test_theme_from_brand_partial_color_omits_missing_mode_mapping(): dark: "#222222" primary: light: "#0066cc" + link: + light: "#0055aa" typography: headings: color: background @@ -400,69 +427,51 @@ def test_theme_from_brand_partial_color_omits_missing_mode_mapping(): f.write(brand_txt) theme = Theme.from_brand(tmpdir) - css = theme.to_css() + css = theme.to_css({"output_style": "expanded"}) assert "--brand-color-background: #222222" in css - assert "--bs-body-bg: var(--brand-color-background)" in css assert css.count("--brand-color-background: #222222") == 1 - assert css.count("--bs-body-bg: var(--brand-color-background)") == 1 assert "--brand-color-primary: #0066cc" in css - assert "--bs-primary: var(--brand-color-primary)" in css - mode_blocks = [ - (mode, body) - for mode, body in re.findall( - r'\[data-bs-theme="(light|dark)"\]\{([^}]*)\}', css - ) - if "--brand-" in body - ] - assert [mode for mode, _ in mode_blocks] == ["light", "dark", "light", "dark"] - light_custom = mode_blocks[0][1] - dark_custom = mode_blocks[1][1] - light_mapping = mode_blocks[2][1] - dark_mapping = mode_blocks[3][1] - - assert "--brand-color-background" not in light_custom - assert "--brand-typography-headings-color" not in light_custom - assert "--bs-body-bg: var(--brand-color-background)" not in light_custom - assert "--brand-color-primary: #0066cc" in light_custom - assert "--bs-primary: var(--brand-color-primary)" in light_mapping - assert "--bs-heading-color" not in light_mapping - assert "--brand-color-primary" not in dark_custom - assert "--brand-typography-headings-color: #222222" in dark_custom - assert "--brand-color-primary" not in dark_mapping - assert "--bs-heading-color: var(--brand-typography-headings-color)" in dark_mapping - assert "--brand-typography-monospace-inline-background-color" not in light_custom - assert "--brand-typography-monospace-block-color" in light_custom - assert "--brand-typography-monospace-block-background-color" not in light_custom - assert ( - "--brand-typography-monospace-inline-background-color: #303030" in dark_custom + light_root = _css_rule_body(css, "html:not([data-bs-theme])") + explicit_light_root = _css_rule_body(css, '[data-bs-theme="light"]') + dark_root = _css_rule_body(css, '[data-bs-theme="dark"]') + + assert "--bs-primary:#0066cc" in light_root + assert "--bs-primary-rgb:0,102,204" in light_root + assert "--bs-primary:#0066cc" in explicit_light_root + assert "--bs-link-color:#0055aa" in light_root + assert "--bs-link-color-rgb:0,85,170" in light_root + assert "--bs-body-bg:" not in light_root + assert "--bs-heading-color:" not in light_root + + assert "--bs-primary:" not in dark_root + assert "--bs-primary-rgb:" not in dark_root + assert "--bs-link-color:" not in dark_root + assert "--bs-link-color-rgb:" not in dark_root + assert "--bs-body-bg:#222222" in dark_root + assert "--bs-body-bg-rgb:34,34,34" in dark_root + assert "--bs-heading-color:#222222" in dark_root + assert "--bs-code-bg:#303030" in dark_root + assert "--bs-pre-bg:#181818" in dark_root + assert "--bs-pre-color:" not in dark_root + + assert "--bs-btn-bg:#0066cc" in _css_rule_body( + css, "html:not([data-bs-theme]) .btn-primary" ) - assert "--brand-typography-monospace-block-background-color: #181818" in dark_custom + assert '[data-bs-theme="dark"] .btn-primary{' not in css + assert "html:not([data-bs-theme]) code:not(pre > code) {" not in css assert ( - "--bs-code-bg: var(--brand-typography-monospace-inline-background-color)" - in dark_mapping + _css_rule_body(css, '[data-bs-theme="dark"] code:not(pre > code)') + == "background-color:var(--bs-code-bg);" ) assert ( - "--bs-pre-bg: var(--brand-typography-monospace-block-background-color)" - in dark_mapping - ) - assert ( - "--bs-pre-color: var(--brand-typography-monospace-block-color)" - not in dark_mapping - ) - assert '[data-bs-theme="light"] code:not(pre>code)' not in css - assert ( - _css_rule_body(css, '[data-bs-theme="dark"] code:not(pre>code)') - == "background-color:var(--bs-code-bg)" - ) - assert ( - _css_rule_body(css, '[data-bs-theme="light"] pre') - == "color:var(--bs-pre-color)" + _css_rule_body(css, "html:not([data-bs-theme]) pre") + == "color:var(--bs-pre-color);" ) assert ( _css_rule_body(css, '[data-bs-theme="dark"] pre') - == "background-color:var(--bs-pre-bg)" + == "background-color:var(--bs-pre-bg);" ) @@ -487,12 +496,10 @@ def test_theme_from_brand_scalar_values_are_emitted_in_both_modes(): f.write(brand_txt) theme = Theme.from_brand(tmpdir) - css = theme.to_css() + css = theme.to_css({"output_style": "expanded"}) assert css.count("--brand-color-primary: #0066cc") == 2 - assert css.count("--bs-primary: var(--brand-color-primary)") == 2 assert css.count("--brand-typography-headings-color: #333333") == 2 - assert css.count("--bs-heading-color: var(--brand-typography-headings-color)") == 2 assert css.count("--brand-typography-monospace-inline-color: #111111") == 2 assert ( css.count("--brand-typography-monospace-inline-background-color: #f1f5fa") == 2 @@ -501,10 +508,23 @@ def test_theme_from_brand_scalar_values_are_emitted_in_both_modes(): assert ( css.count("--brand-typography-monospace-block-background-color: #f8f9fa") == 2 ) - assert css.count('[data-bs-theme="light"] code:not(pre>code)') == 1 - assert css.count('[data-bs-theme="dark"] code:not(pre>code)') == 1 - assert css.count('[data-bs-theme="light"] pre') == 1 - assert css.count('[data-bs-theme="dark"] pre') == 1 + light_root = _css_rule_body(css, "html:not([data-bs-theme])") + dark_root = _css_rule_body(css, '[data-bs-theme="dark"]') + for root in (light_root, dark_root): + assert "--bs-primary:#0066cc" in root + assert "--bs-primary-rgb:0,102,204" in root + assert "--bs-heading-color:#333333" in root + assert "--bs-code-color:#111111" in root + assert "--bs-code-bg:#f1f5fa" in root + assert "--bs-pre-color:#222222" in root + assert "--bs-pre-bg:#f8f9fa" in root + + assert "--bs-btn-bg:#0066cc" in _css_rule_body( + css, "html:not([data-bs-theme]) .btn-primary" + ) + assert "--bs-btn-bg:#0066cc" in _css_rule_body( + css, '[data-bs-theme="dark"] .btn-primary' + ) @skip_on_windows @@ -529,3 +549,66 @@ def test_theme_from_brand_retains_variant_values_for_runtime_switching(): assert primary.light == "#0066cc" assert primary.dark == "#66b2ff" assert all("brand_color_primary: {" not in default for default in theme._defaults) + + +@skip_on_windows +def test_theme_from_brand_primary_supplies_default_link_color(): + brand_txt = """ +color: + primary: + light: "#cc0000" + dark: "#00aa44" +""" + + with tempfile.TemporaryDirectory() as tmpdir: + with open(f"{tmpdir}/_brand.yml", "w") as f: + f.write(brand_txt) + + css = Theme.from_brand(tmpdir).to_css({"output_style": "expanded"}) + + assert "--bs-link-color:#cc0000" in _css_rule_body(css, "html:not([data-bs-theme])") + assert "--bs-link-color-rgb:204,0,0" in _css_rule_body( + css, "html:not([data-bs-theme])" + ) + assert "--bs-link-color:#00aa44" in _css_rule_body(css, '[data-bs-theme="dark"]') + assert "--bs-link-color-rgb:0,170,68" in _css_rule_body( + css, '[data-bs-theme="dark"]' + ) + + +@skip_on_windows +def test_theme_from_brand_respects_custom_bootstrap_prefix(): + brand_txt = """ +color: + foreground: + light: "#111111" + dark: "#eeeeee" + primary: + light: "#cc0000" + dark: "#00aa44" +defaults: + bootstrap: + defaults: + prefix: acme- +""" + + with tempfile.TemporaryDirectory() as tmpdir: + with open(f"{tmpdir}/_brand.yml", "w") as f: + f.write(brand_txt) + + css = Theme.from_brand(tmpdir).to_css({"output_style": "expanded"}) + + light_root = _css_rule_body(css, "html:not([data-bs-theme])") + dark_root = _css_rule_body(css, '[data-bs-theme="dark"]') + light_button = _css_rule_body(css, "html:not([data-bs-theme]) .btn-primary") + dark_button = _css_rule_body(css, '[data-bs-theme="dark"] .btn-primary') + + assert "--acme-body-color:#111111" in light_root + assert "--acme-body-color-rgb:17,17,17" in light_root + assert "--acme-primary:#cc0000" in light_root + assert "--acme-primary-rgb:204,0,0" in light_root + assert "--acme-btn-bg:#cc0000" in light_button + assert "--acme-body-color:#eeeeee" in dark_root + assert "--acme-primary:#00aa44" in dark_root + assert "--acme-btn-bg:#00aa44" in dark_button + assert "--bs-primary:var(--brand-color-primary)" not in css From 31927e1547ae7b4e2225f81d096dcc6fcddd75d3 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 12 Aug 2026 14:03:01 -0400 Subject: [PATCH 5/5] fix(theme): Install brand-yml from GitHub --- pyproject.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 262fded59f..62733fd7b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,11 @@ dependencies = [ ] [project.optional-dependencies] -theme = ["libsass>=0.23.0", "brand_yml>=0.2.1"] +# TODO: Restore a version floor after posit-dev/brand-yml#120 is released. +theme = [ + "libsass>=0.23.0", + "brand_yml @ git+https://github.com/posit-dev/brand-yml.git@4f216cacc4b03e45a0e7beef6542d428f46df1f9", +] otel = [ # 1.24.0+ required: same sanitized-stack-trace floor as opentelemetry-api above "opentelemetry-sdk>=1.24.0", @@ -151,7 +155,7 @@ dev = [ "Flake8-pyproject>=1.2.3", "isort>=5.10.1", "libsass>=0.23.0", - "brand_yml>=0.2.1", + "brand_yml @ git+https://github.com/posit-dev/brand-yml.git@4f216cacc4b03e45a0e7beef6542d428f46df1f9", "pyrefly>=1.1.1", "pyright>=1.1.407", "pre-commit>=2.15.0", @@ -286,4 +290,3 @@ matches = "tests/playwright/**" # The repository has separate top-level conftest modules for pytest and # Playwright. A single project search path cannot disambiguate both names. replace-imports-with-any = ["conftest"] -