diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ec3469d2..8945c577df 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 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) ### Bug fixes diff --git a/pyproject.toml b/pyproject.toml index 314ddb5267..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.0"] +# 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.0", + "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"] - 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 7ddca99a2d..acfaa94556 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,17 @@ def join_str(x: str | None, y: str | None): class ThemeBrand(Theme): + _THEME_COLOR_NAMES = ( + "primary", + "secondary", + "success", + "info", + "warning", + "danger", + "light", + "dark", + ) + def __init__( self, brand: "Brand", @@ -224,6 +235,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 +267,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 +327,151 @@ 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 _color_value(value: Any, mode: str) -> str | None: + if isinstance(value, str): + return value + + 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 complete Bootstrap color-mode layers. + + 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": 'html:not([data-bs-theme]), [data-bs-theme="light"]', + "dark": '[data-bs-theme="dark"]', + } + ) + 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_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) self.add_defaults(f"\n// *---- brand: {header} ----* //") 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 129de13b45..4cef9af504 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 @@ -106,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: + matches = re.findall(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", css) + assert matches + return re.sub(r"\s+", "", matches[-1]) + + def test_theme_update_preset(): theme = Theme("shiny") assert theme._preset == "shiny" @@ -284,5 +291,324 @@ 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_complete_mode_layers(): + 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 + background-color: + light: "#f1f5fa" + dark: "#263746" + monospace-block: + color: foreground + background-color: + light: "#f8f9fa" + dark: "#1f2933" + 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({"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 + + 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 "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)" in _css_rule_body( + css, "html:not([data-bs-theme]) pre" + ) + assert "background-color:var(--bs-pre-bg)" in _css_rule_body( + css, '[data-bs-theme="dark"] pre' + ) + + +@skip_on_windows +def test_theme_from_brand_partial_color_omits_missing_mode_mapping(): + brand_txt = """ +color: + background: + dark: "#222222" + primary: + light: "#0066cc" + link: + light: "#0055aa" +typography: + headings: + color: background + monospace-inline: + background-color: + dark: "#303030" + monospace-block: + color: + light: "#202020" + background-color: + dark: "#181818" +""" + + 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({"output_style": "expanded"}) + + assert "--brand-color-background: #222222" in css + assert css.count("--brand-color-background: #222222") == 1 + assert "--brand-color-primary: #0066cc" in css + + 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 '[data-bs-theme="dark"] .btn-primary{' not in css + assert "html:not([data-bs-theme]) 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, "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);" + ) + + +@skip_on_windows +def test_theme_from_brand_scalar_values_are_emitted_in_both_modes(): + brand_txt = """ +color: + primary: "#0066cc" +typography: + headings: + color: "#333333" + monospace-inline: + color: "#111111" + background-color: "#f1f5fa" + monospace-block: + color: "#222222" + background-color: "#f8f9fa" +""" + + 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({"output_style": "expanded"}) + + assert css.count("--brand-color-primary: #0066cc") == 2 + assert css.count("--brand-typography-headings-color: #333333") == 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 + ) + 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 +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 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) + + +@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