From 41b87978874ecac685151361feb876569d30e83d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 31 Dec 2024 14:17:36 -0500 Subject: [PATCH] refactor: Move `ui.Theme.from_brand()` Sass code into an `.scss` file (#1792) --- examples/brand/app.py | 5 +- shiny/ui/_theme_brand.py | 370 +++++------------- shiny/www/py-shiny/brand/_brand-yml.scss | 215 ++++++++++ .../sass/bslib/lib/bs5/scss/_variables.scss | 2 +- tests/pytest/test_theme.py | 31 ++ 5 files changed, 337 insertions(+), 286 deletions(-) create mode 100644 shiny/www/py-shiny/brand/_brand-yml.scss diff --git a/examples/brand/app.py b/examples/brand/app.py index 3f45e0645..e952cb53d 100644 --- a/examples/brand/app.py +++ b/examples/brand/app.py @@ -1,4 +1,3 @@ -import os from pathlib import Path import matplotlib.pyplot as plt @@ -7,10 +6,8 @@ from shiny import App, render, ui from shiny.ui._theme_brand import bootstrap_colors -# TODO: Move this into the test that runs this app -os.environ["SHINY_BRAND_YML_RAISE_UNMAPPED"] = "true" theme = ui.Theme.from_brand(__file__) -# theme = ui.Theme() +# theme = ui.Theme() ## default theme theme.add_rules((Path(__file__).parent / "_colors.scss").read_text()) app_ui = ui.page_navbar( diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 056c2bf28..d9c6f77df 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import warnings from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Union @@ -11,48 +10,10 @@ from .._versions import bootstrap as v_bootstrap from ._theme import Theme +from ._utils import path_pkg_www YamlScalarType = Union[str, int, bool, float, None] - -class ThemeBrandUnmappedFieldError(ValueError): - def __init__(self, field: str): - self.field = field - self.message = f"Unmapped brand.yml field: {field}" - super().__init__(self.message) - - def __str__(self): - return self.message - - -def warn_or_raise_unmapped_variable(unmapped: str): - if os.environ.get("SHINY_BRAND_YML_RAISE_UNMAPPED") == "true": - raise ThemeBrandUnmappedFieldError(unmapped) - else: - warnings.warn( - f"Shiny's brand.yml theme does not yet support {unmapped}.", - stacklevel=4, - ) - - -color_map: dict[str, list[str]] = { - # Bootstrap uses $gray-900 and $white for the body bg-color by default, and then - # swaps them for $gray-100 and $gray-900 in dark mode. brand.yml may end up with - # light/dark variants for foreground/background, see posit-dev/brand-yml#38. - "foreground": ["brand--foreground", "body-color", "body-bg-dark"], - "background": ["brand--background", "body-bg", "body-color-dark"], - "primary": ["primary"], - "secondary": ["secondary", "body-secondary-color", "body-secondary"], - "tertiary": ["body-tertiary-color", "body-tertiary"], - "success": ["success"], - "info": ["info"], - "warning": ["warning"], - "danger": ["danger"], - "light": ["light"], - "dark": ["dark"], -} -"""Maps brand.color fields to Bootstrap Sass variables""" - # https://github.com/twbs/bootstrap/blob/6e1f75/scss/_variables.scss#L38-L49 bootstrap_colors: list[str] = [ "white", @@ -77,50 +38,6 @@ def warn_or_raise_unmapped_variable(unmapped: str): * [Bootstrap 5 - Colors](https://getbootstrap.com/docs/5.3/customize/color/#color-sass-maps) """ -# TODO: test that these Sass variables exist in Bootstrap -typography_map: dict[str, dict[str, list[str]]] = { - "base": { - "family": ["font-family-base"], - "size": ["font-size-base"], # TODO: consider using $font-size-root instead - "line_height": ["line-height-base"], - "weight": ["font-weight-base"], - }, - "headings": { - "family": ["headings-font-family"], - "line_height": ["headings-line-height"], - "weight": ["headings-font-weight"], - "color": ["headings-color"], - "style": ["headings-style"], - }, - "monospace": { - "family": ["font-family-monospace"], - "size": ["code-font-size"], - "weight": ["code-font-weight"], - }, - "monospace_inline": { - "family": ["font-family-monospace-inline"], - "color": ["code-color", "code-color-dark"], - "background_color": ["code-bg"], - "size": ["code-inline-font-size"], - "weight": ["code-inline-font-weight"], - }, - "monospace_block": { - "family": ["font-family-monospace-block"], - "line_height": ["code-block-line-height"], - "color": ["pre-color"], - "background_color": ["pre-bg"], - "weight": ["code-block-font-weight"], - "size": ["code-block-font-size"], - }, - "link": { - "background_color": ["link-bg"], - "color": ["link-color", "link-color-dark"], - "weight": ["link-weight"], - "decoration": ["link-decoration"], - }, -} -"""Maps brand.typography fields to corresponding Bootstrap Sass variables""" - class BrandBootstrapConfigFromYaml: """Validate a Bootstrap config from a YAML source""" @@ -135,7 +52,6 @@ def __init__( mixins: Any = None, rules: Any = None, ): - # TODO: Remove `path` and handle in try/except block in caller self._path = path self.version = version @@ -266,7 +182,6 @@ def __init__( *, include_paths: Optional[str | Path | list[str | Path]] = None, ): - name = self._get_theme_name(brand) brand_bootstrap = BrandBootstrapConfig.from_brand(brand) @@ -278,35 +193,38 @@ def __init__( ) self.brand = brand + self.add_sass_layer_file( + path_pkg_www("..", "py-shiny", "brand", "_brand-yml.scss") + ) # Prep Sass and CSS Variables ------------------------------------------------- - sass_vars_theme_colors, sass_vars_brand_colors, css_vars_brand = ( - ThemeBrand._prepare_color_vars(brand) - ) - sass_vars_typography = ThemeBrand._prepare_typography_vars(brand) - - # Theme ----------------------------------------------------------------------- - # Defaults are added in reverse order, so each chunk appears above the next - # layer of defaults. The intended order in the final output is: - # 1. "Brand" Color palette - # 2. "Brand" Bootstrap Sass vars - # 3. "Brand" theme colors - # 4. "Brand" typography - # 5. Gray scale variables from "Brand" fg/bg or black/white - # 6. Fallback vars needed by additional "Brand" rules - - self.add_defaults("", "// *---- brand: end of defaults ----* //", "") - self._add_sass_ensure_variables() - self._add_sass_brand_grays() - self._add_defaults_hdr("typography", **sass_vars_typography) - self._add_defaults_hdr("theme colors", **sass_vars_theme_colors) + ( + brand_color_palette_defaults, + brand_color_defaults, + brand_color_palette_rules, + ) = ThemeBrand._prepare_color_vars(brand) + + brand_typography_defaults = ThemeBrand._prepare_typography_vars(brand) + + # Defaults ---- + # Final order is reverse-insertion: + # * brand.color.palette + # * brand.defaults (Brand-defined Bootstrap defaults) + # * brand.color + # * brand.typography + + self._add_defaults_hdr("typography", **brand_typography_defaults) + self._add_defaults_hdr("color", **brand_color_defaults) + if brand_bootstrap.defaults: - self._add_defaults_hdr("bootstrap defaults", **brand_bootstrap.defaults) - self._add_defaults_hdr("brand colors", **sass_vars_brand_colors) + self._add_defaults_hdr("defaults (bootstrap)", **(brand_bootstrap.defaults)) + + self._add_defaults_hdr("color.palette", **brand_color_palette_defaults) + + # Rules ---- + self.add_rules(*brand_color_palette_rules) - # "Brand" rules (now in forwards order) - self._add_rules_brand_colors(css_vars_brand) - self._add_sass_brand_rules() + # Bootstrap extras: functions, mixins, rules (defaults handled above) self._add_brand_bootstrap_other(brand_bootstrap) def _get_theme_name(self, brand: "Brand") -> str: @@ -318,45 +236,67 @@ def _get_theme_name(self, brand: "Brand") -> str: @staticmethod def _prepare_color_vars( brand: "Brand", - ) -> tuple[dict[str, str], dict[str, str], list[str]]: - """Colors: create a dictionary of Sass variables and a list of brand CSS variables""" + ) -> tuple[dict[str, YamlScalarType], dict[str, YamlScalarType], list[str]]: + """ + Colors: Create a dictionaries of Sass and CSS variables + """ if not brand.color: return {}, {}, [] - mapped: dict[str, str] = {} - brand_sass_vars: dict[str, str] = {} - brand_css_vars: list[str] = [] + defaults_dict: dict[str, YamlScalarType] = {} + palette_defaults_dict: dict[str, YamlScalarType] = {} + palette_css_vars: list[str] = [] - # Map values in colors to their Sass variable counterparts for thm_name, thm_color in brand.color.to_dict(include="theme").items(): - if thm_name not in color_map: - warn_or_raise_unmapped_variable(f"color.{thm_name}") - continue + # Create brand Sass variables and set related Bootstrap Sass vars + # brand.color.primary = "#007bff" + # ==> $brand_color_primary: #007bff !default; + # ==> $primary: $brand_color_primary !default; - for sass_var in color_map[thm_name]: - mapped[sass_var] = thm_color + brand_color_var = f"brand_color_{thm_name}" + 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 pal_name in bootstrap_colors: - mapped[pal_name] = pal_color + defaults_dict[pal_name] = pal_color # Create Sass and CSS variables for the brand color palette # => Sass var: `$brand-{name}: {value}` - brand_sass_vars.update({f"brand-{pal_name}": pal_color}) + palette_defaults_dict.update({f"brand-{pal_name}": pal_color}) # => CSS var: `--brand-{name}: {value}` - brand_css_vars.append(f"--brand-{pal_name}: {pal_color};") - - # We keep Sass and "Brand" vars separate so we can ensure "Brand" Sass vars come - # first in the compiled Sass definitions. - return mapped, brand_sass_vars, brand_css_vars + palette_css_vars.append(f" --brand-{pal_name}: {pal_color};") + + palette_rules = [ + "// *---- brand.color.palette ----* //", + ":root {", + *palette_css_vars, + "}", + ] + + return ( + palette_defaults_dict, # brand.color.palette:defaults + defaults_dict, # brand.color:defaults + palette_rules, # brand.color.palette:rules + ) @staticmethod - def _prepare_typography_vars(brand: "Brand") -> dict[str, str]: - """Typography: Create a list of Bootstrap Sass variables""" - mapped: dict[str, str] = {} + def _prepare_typography_vars(brand: "Brand") -> dict[str, YamlScalarType]: + """ + Typography: Create a list of brand Sass variables + + Creates a dictionary of Sass variables for typography settings defined in the + `brand` object. These are used to set brand Sass variables in the format + `$brand_typography_{field}_{prop}`, for example: + + ```scss + $brand_typography_base_size: 16rem; + $brand_typography_base_line-height: 1.25; + ``` + """ + mapped: dict[str, YamlScalarType] = {} if not brand.typography: return mapped @@ -368,17 +308,9 @@ def _prepare_typography_vars(brand: "Brand") -> dict[str, str]: ) for field, prop in brand_typography.items(): - if field not in typography_map: - warn_or_raise_unmapped_variable(f"typography.{field}") - continue - for prop_key, prop_value in prop.items(): - if prop_key in typography_map[field]: - typo_sass_vars = typography_map[field][prop_key] - for typo_sass_var in typo_sass_vars: - mapped[typo_sass_var] = prop_value - else: - warn_or_raise_unmapped_variable(f"typography.{field}.{prop_key}") + typo_sass_var = f"brand_typography_{field}_{prop_key}" + mapped[typo_sass_var] = prop_value return mapped @@ -386,149 +318,25 @@ def _add_defaults_hdr(self, header: str, **kwargs: YamlScalarType): self.add_defaults(**kwargs) self.add_defaults(f"\n// *---- brand: {header} ----* //") - def _add_sass_ensure_variables(self): - """Ensure the variables we create to augment Bootstrap's variables exist""" - self._add_defaults_hdr( - "added variables", - **{ - "code-font-weight": None, - "font-family-monospace-inline": None, - "code-inline-font-weight": None, - "code-inline-font-size": None, - "font-family-monospace-block": None, - "code-block-font-weight": None, - "code-block-font-size": None, - "code-block-line-height": None, - "link-bg": None, - "link-weight": None, - }, - ) - - def _add_sass_brand_grays(self): - """ - Adds functions and defaults to handle creating a gray scale palette from the - brand color palette, or the brand's foreground/background colors. - """ - self.add_functions( - """ - @function brand-choose-white-black($foreground, $background) { - $lum_fg: luminance($foreground); - $lum_bg: luminance($background); - $contrast: contrast-ratio($foreground, $background); - - @if $contrast < 4.5 { - @warn "The contrast ratio of #{$contrast} between the brand's foreground color (#{inspect($foreground)}) and background color (#{inspect($background)}) is very low. Consider picking colors with higher contrast for better readability."; - } - - $white: if($lum_fg > $lum_bg, $foreground, $background); - $black: if($lum_fg <= $lum_bg, $foreground, $background); - - // If the brand foreground/background are close enough to black/white, we - // use those values. Otherwise, we'll mix the white/black from the brand - // fg/bg with actual white and black to get something much closer. - @return ( - "white": if(contrast-ratio($white, white) <= 1.15, $white, mix($white, white, 20%)), - "black": if(contrast-ratio($black, black) <= 1.15, $black, mix($black, black, 20%)), - ); - } - """ - ) - self.add_defaults( - """ - // *---- brand: automatic gray gradient ----* // - $enable-brand-grays: true !default; - // Ensure these variables exist so that we can set them inside of @if context - // They can still be overwritten by the user, even with !default; - $white: null !default; - $black: null !default; - $gray-100: null !default; - $gray-200: null !default; - $gray-300: null !default; - $gray-400: null !default; - $gray-500: null !default; - $gray-600: null !default; - $gray-700: null !default; - $gray-800: null !default; - $gray-900: null !default; - - @if $enable-brand-grays { - @if variable-exists(brand--foreground) and variable-exists(brand--background) { - $brand-white-black: brand-choose-white-black($brand--foreground, $brand--background); - @if $white == null { - $white: map-get($brand-white-black, "white") !default; - } - @if $black == null { - $black: map-get($brand-white-black, "black") !default; - } - } - @if $white != null and $black != null { - $gray-100: mix($white, $black, 90%) !default; - $gray-200: mix($white, $black, 80%) !default; - $gray-300: mix($white, $black, 70%) !default; - $gray-400: mix($white, $black, 60%) !default; - $gray-500: mix($white, $black, 50%) !default; - $gray-600: mix($white, $black, 40%) !default; - $gray-700: mix($white, $black, 30%) !default; - $gray-800: mix($white, $black, 20%) !default; - $gray-900: mix($white, $black, 10%) !default; - } - } - """ - ) - - def _add_sass_brand_rules(self): - """Additional rules to fill in Bootstrap styles for "Brand" parameters""" - self.add_rules( - """ - // *---- brand: brand rules to augment Bootstrap rules ----* // - // https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_root.scss#L82 - :root { - --#{$prefix}link-bg: #{$link-bg}; - --#{$prefix}link-weight: #{$link-weight}; - } - // https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_reboot.scss#L244 - a { - background-color: var(--#{$prefix}link-bg); - font-weight: var(--#{$prefix}link-weight); - } - code { - font-weight: $code-font-weight; - } - code:not(pre > code) { - font-family: $font-family-monospace-inline; - font-weight: $code-inline-font-weight; - font-size: $code-inline-font-size; - } - // https://github.com/twbs/bootstrap/blob/30e01525/scss/_reboot.scss#L287 - pre { - font-family: $font-family-monospace-block; - font-weight: $code-block-font-weight; - font-size: $code-block-font-size; - line-height: $code-block-line-height; - } - - $bslib-dashboard-design: false !default; - @if $bslib-dashboard-design and variable-exists(brand--background) { - // When brand makes dark mode, it usually hides card definition, so we add - // back card borders in dark mode. - [data-bs-theme="dark"] { - --bslib-card-border-color: RGBA(255, 255, 255, 0.15); - } - } - """ - ) - - def _add_rules_brand_colors(self, css_vars_colors: list[str]): - self.add_rules("\n// *---- brand.color.palette ----* //") - self.add_rules(":root {", *css_vars_colors, "}") - def _add_brand_bootstrap_other(self, bootstrap: BrandBootstrapConfig): if bootstrap.functions: - self.add_functions(bootstrap.functions) + self.add_functions( + *[ + "// *---- brand.defaults: bootstrap.functions ----* //", + bootstrap.functions, + ] + ) if bootstrap.mixins: - self.add_mixins(bootstrap.mixins) + self.add_mixins( + *[ + "// *---- brand.defaults: bootstrap.mixins ----* //", + bootstrap.mixins, + ] + ) if bootstrap.rules: - self.add_rules(bootstrap.rules) + self.add_rules( + *["// *---- brand.defaults: bootstrap.rules ----* //", bootstrap.rules] + ) def _html_dependencies(self) -> list[HTMLDependency]: theme_deps = super()._html_dependencies() diff --git a/shiny/www/py-shiny/brand/_brand-yml.scss b/shiny/www/py-shiny/brand/_brand-yml.scss new file mode 100644 index 000000000..de6a1f6f4 --- /dev/null +++ b/shiny/www/py-shiny/brand/_brand-yml.scss @@ -0,0 +1,215 @@ +/*-- scss:functions --*/ +@function brand-choose-white-black($foreground, $background) { + $lum_fg: luminance($foreground); + $lum_bg: luminance($background); + $contrast: contrast-ratio($foreground, $background); + + @if $contrast < 4.5 { + @warn "The contrast ratio of #{$contrast} between the brand's foreground color (#{inspect($foreground)}) and background color (#{inspect($background)}) is very low. Consider picking colors with higher contrast for better readability."; + } + + $white: if($lum_fg > $lum_bg, $foreground, $background); + $black: if($lum_fg <= $lum_bg, $foreground, $background); + + // If the brand foreground/background are close enough to black/white, we + // use those values. Otherwise, we'll mix the white/black from the brand + // fg/bg with actual white and black to get something much closer. + @return ( + "white": if(contrast-ratio($white, white) <= 1.15, $white, mix($white, white, 20%)), + "black": if(contrast-ratio($black, black) <= 1.15, $black, mix($black, black, 20%)) + ); +} + +/*-- scss:defaults --*/ + +// Sass variables from `brand` will be inserted (above) here in this order: +// * brand.color.palette +// * brand.defaults (Brand-defined Bootstrap defaults) +// * brand.color +// * brand.typography + +//*-- brand: initial defaults --*// +$brand_color_foreground: null !default; +$brand_color_background: null !default; +$brand_color_primary: null !default; +$brand_color_secondary: null !default; +$brand_color_tertiary: null !default; +$brand_color_success: null !default; +$brand_color_info: null !default; +$brand_color_warning: null !default; +$brand_color_danger: null !default; +$brand_color_light: null !default; +$brand_color_dark: null !default; +$brand_typography_base_family: null !default; +$brand_typography_base_size: null !default; +$brand_typography_base_line-height: null !default; +$brand_typography_base_weight: null !default; +$brand_typography_headings_family: null !default; +$brand_typography_headings_line-height: null !default; +$brand_typography_headings_weight: null !default; +$brand_typography_headings_color: null !default; +$brand_typography_headings_style: null !default; +$brand_typography_monospace_family: null !default; +$brand_typography_monospace_size: null !default; +$brand_typography_monospace_weight: null !default; +$brand_typography_monospace-inline_family: null !default; +$brand_typography_monospace-inline_color: null !default; +$brand_typography_monospace-inline_background-color: null !default; +$brand_typography_monospace-inline_size: null !default; +$brand_typography_monospace-inline_weight: null !default; +$brand_typography_monospace-block_family: null !default; +$brand_typography_monospace-block_line-height: null !default; +$brand_typography_monospace-block_color: null !default; +$brand_typography_monospace-block_background-color: null !default; +$brand_typography_monospace-block_weight: null !default; +$brand_typography_monospace-block_size: null !default; +$brand_typography_link_background-color: null !default; +$brand_typography_link_color: null !default; +$brand_typography_link_weight: null !default; +$brand_typography_link_decoration: null !default; + +//*-- brand.color --*// +$primary: $brand_color_primary !default; +$secondary: $brand_color_secondary !default; +$tertiary: $brand_color_tertiary !default; +$success: $brand_color_success !default; +$info: $brand_color_info !default; +$warning: $brand_color_warning !default; +$danger: $brand_color_danger !default; +$light: $brand_color_light !default; +$dark: $brand_color_dark !default; + +$body-color: $brand_color_foreground !default; +$body-bg-dark: $brand_color_foreground !default; +$body-bg: $brand_color_background !default; +$body-color-dark: $brand_color_background !default; +$body-secondary-color: $brand_color_secondary !default; +$body-secondary: $brand_color_secondary !default; +$body-tertiary-color: $brand_color_tertiary !default; +$body-tertiary: $brand_color_tertiary !default; + +//*-- brand.typography --*// +// brand.typography.base +$font-family-base: $brand_typography_base_family !default; +$font-size-base: $brand_typography_base_size !default; +$line-height-base: $brand_typography_base_line-height !default; +$font-weight-base: $brand_typography_base_weight !default; +// brand.typography.headings +$headings-font-family: $brand_typography_headings_family !default; +$headings-line-height: $brand_typography_headings_line-height !default; +$headings-font-weight: $brand_typography_headings_weight !default; +$headings-color: $brand_typography_headings_color !default; +$headings-style: $brand_typography_headings_style !default; +// brand.typography.monospace +$font-family-monospace: $brand_typography_monospace_family !default; +$code-font-size: $brand_typography_monospace_size !default; +$code-font-weight: $brand_typography_monospace_weight !default; +// brand.typography.monospace_inline +$font-family-monospace-inline: $brand_typography_monospace-inline_family !default; +$code-color: $brand_typography_monospace-inline_color !default; +$code-color-dark: $brand_typography_monospace-inline_color !default; +$code-bg: $brand_typography_monospace-inline_background-color !default; +$code-inline-font-size: $brand_typography_monospace-inline_size !default; +$code-inline-font-weight: $brand_typography_monospace-inline_weight !default; +// brand.typography.monospace_block +$font-family-monospace-block: $brand_typography_monospace-block_family !default; +$code-block-line-height: $brand_typography_monospace-block_line-height !default; +$pre-color: $brand_typography_monospace-block_color !default; +$pre-bg: $brand_typography_monospace-block_background-color !default; +$code-block-font-weight: $brand_typography_monospace-block_weight !default; +$code-block-font-size: $brand_typography_monospace-block_size !default; +// brand.typography.link +$link-bg: $brand_typography_link_background-color !default; +$link-color: $brand_typography_link_color !default; +$link-color-dark: $brand_typography_link_color !default; +$link-weight: $brand_typography_link_weight !default; +$link-decoration: $brand_typography_link_decoration !default; + +// *---- brand: automatic gray gradient ----* // +$enable-brand-grays: true !default; +// Ensure these variables exist so that we can set them inside of @if context +// They can still be overwritten by the user, even with !default; +$white: null !default; +$black: null !default; +$gray-100: null !default; +$gray-200: null !default; +$gray-300: null !default; +$gray-400: null !default; +$gray-500: null !default; +$gray-600: null !default; +$gray-700: null !default; +$gray-800: null !default; +$gray-900: null !default; + +@if $enable-brand-grays { + @if $brand_color_foreground != null and $brand_color_background != null { + $brand-white-black: brand-choose-white-black($brand_color_foreground, $brand_color_background); + @if $white == null { + $white: map-get($brand-white-black, "white") !default; + } + @if $black == null { + $black: map-get($brand-white-black, "black") !default; + } + } + @if $white != null and $black != null { + $gray-100: mix($white, $black, 90%) !default; + $gray-200: mix($white, $black, 80%) !default; + $gray-300: mix($white, $black, 70%) !default; + $gray-400: mix($white, $black, 60%) !default; + $gray-500: mix($white, $black, 50%) !default; + $gray-600: mix($white, $black, 40%) !default; + $gray-700: mix($white, $black, 30%) !default; + $gray-800: mix($white, $black, 20%) !default; + $gray-900: mix($white, $black, 10%) !default; + } +} + +// *---- brand: added variables ----* // +$code-font-weight: null !default; +$font-family-monospace-inline: null !default; +$code-inline-font-weight: null !default; +$code-inline-font-size: null !default; +$font-family-monospace-block: null !default; +$code-block-font-weight: null !default; +$code-block-font-size: null !default; +$code-block-line-height: null !default; +$link-bg: null !default; +$link-weight: null !default; + +/*-- scss:rules --*/ + +// *---- brand: brand rules to augment Bootstrap rules ----* // +// https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_root.scss#L82 +:root { + --#{$prefix}link-bg: #{$link-bg}; + --#{$prefix}link-weight: #{$link-weight}; +} +// https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_reboot.scss#L244 +a { + background-color: var(--#{$prefix}link-bg); + font-weight: var(--#{$prefix}link-weight); +} +code { + font-weight: $code-font-weight; +} +code:not(pre > code) { + font-family: $font-family-monospace-inline; + font-weight: $code-inline-font-weight; + font-size: $code-inline-font-size; +} +// https://github.com/twbs/bootstrap/blob/30e01525/scss/_reboot.scss#L287 +pre { + font-family: $font-family-monospace-block; + font-weight: $code-block-font-weight; + font-size: $code-block-font-size; + line-height: $code-block-line-height; +} + +$bslib-dashboard-design: false !default; +@if $bslib-dashboard-design and $brand_color_background != null { + // When brand makes dark mode, it usually hides card definition, so we add + // back card borders in dark mode. + [data-bs-theme="dark"] { + --bslib-card-border-color: RGBA(255, 255, 255, 0.15); + } +} diff --git a/shiny/www/shared/sass/bslib/lib/bs5/scss/_variables.scss b/shiny/www/shared/sass/bslib/lib/bs5/scss/_variables.scss index 5ef7b3b6f..5645c14d2 100644 --- a/shiny/www/shared/sass/bslib/lib/bs5/scss/_variables.scss +++ b/shiny/www/shared/sass/bslib/lib/bs5/scss/_variables.scss @@ -300,7 +300,7 @@ $cyans: ( // Semantically, $secondary is closest to BS3's 'default' theme color; // so use that if specified. Otherwise, use a light instead of dark gray // default color for $default since that's closer to bootstrap 3's default -$default: if(variable-exists("secondary"), $secondary, $gray-300) !default; +$default: if(variable-exists("secondary") and type-of($secondary) == color, $secondary, $gray-300) !default; // scss-docs-start theme-color-variables $primary: $blue !default; diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index 95b5f2850..dfd87202f 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -257,3 +257,34 @@ def test_theme_add_sass_layer_file(): assert theme._defaults == ["// defaults 1\n// defaults 2\n"] assert theme._mixins == ["// mixins\n"] assert theme._rules == ["// rules 1\n// rules 2\n"] + + +@skip_on_windows +@pytest.mark.parametrize("preset", shiny_theme_presets) +def test_theme_from_brand_base_case_compiles(preset: str): + brand_txt = f""" +meta: + name: Brand Test +defaults: + shiny: + theme: + preset: {preset} + """ + + with tempfile.TemporaryDirectory() as tmpdir: + with open(f"{tmpdir}/_brand.yml", "w") as f: + f.write(brand_txt) + + theme = Theme.from_brand(f"{tmpdir}") + + # Check that the theme preset is set from the brand + assert theme.preset == preset + + # Check that the brand Sass layer is included + assert any(["brand-choose" in f for f in theme._functions]) + assert any(["brand: initial" in d for d in theme._defaults]) + assert any(["brand: brand rules" in r for r in theme._rules]) + + # Check that the CSS compiles without error + css = theme.to_css() + assert isinstance(css, str)