From 2e0f444e89a47c66e6c93e440c1fe9123047e253 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 4 Dec 2024 12:28:45 -0500 Subject: [PATCH 01/11] refactor(brand): Move as much logic as possible into _brand-yml.scss layer file --- shiny/ui/_theme_brand.py | 393 +++++++---------------- shiny/www/py-shiny/brand/_brand-yml.scss | 213 ++++++++++++ 2 files changed, 321 insertions(+), 285 deletions(-) create mode 100644 shiny/www/py-shiny/brand/_brand-yml.scss diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 056c2bf28..74bfc3dff 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""" @@ -278,35 +195,30 @@ 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) + ( + brand_color_palette_defaults, + brand_color_defaults, + brand_color_palette_rules, + ) = ThemeBrand._prepare_color_vars(brand) + + brand_typography_defaults = ThemeBrand._prepare_typography_vars(brand) + + brand_bootstrap_defaults = ( + "\n".join(Theme._combine_args_kwargs(kwargs=brand_bootstrap.defaults)) + if brand_bootstrap.defaults + else "" ) - 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) - if brand_bootstrap.defaults: - self._add_defaults_hdr("bootstrap defaults", **brand_bootstrap.defaults) - self._add_defaults_hdr("brand colors", **sass_vars_brand_colors) - - # "Brand" rules (now in forwards order) - self._add_rules_brand_colors(css_vars_brand) - self._add_sass_brand_rules() + + self._insert_sass("brand.color.palette:defaults", brand_color_palette_defaults) + self._insert_sass("brand.defaults:defaults", brand_bootstrap_defaults) + self._insert_sass("brand.color:defaults", brand_color_defaults) + self._insert_sass("brand.typography:defaults", brand_typography_defaults) + self._insert_sass("brand.color.palette:rules", brand_color_palette_rules) self._add_brand_bootstrap_other(brand_bootstrap) def _get_theme_name(self, brand: "Brand") -> str: @@ -315,51 +227,102 @@ def _get_theme_name(self, brand: "Brand") -> str: return brand.meta.name.short or brand.meta.name.full or "brand" + def _insert_sass(self, name: str, code: str): + name_parts = name.split(":") + if len(name_parts) != 2: + raise ValueError( + f"Invalid name format. Expected 'name:layer', got '{name}'" + ) + + layer = name_parts[1] + layer_attr = f"_{layer}" + if not hasattr(self, layer_attr): + raise ValueError(f"Invalid layer: {layer}") + + layer_content = getattr(self, layer_attr) + insert_marker = f"/*-- insert({name}) --*/" + + new_layer_content = [ + chunk.replace(insert_marker, code) for chunk in layer_content + ] + + setattr(self, layer_attr, new_layer_content) + @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[str, str, str]: + """ + Colors: Create a dictionaries of Sass and CSS variables + """ if not brand.color: - return {}, {}, [] + return "", "", "" - mapped: dict[str, str] = {} - brand_sass_vars: dict[str, str] = {} - brand_css_vars: list[str] = [] + defaults_dict: dict[str, str | float | int | bool | None] = {} + palette_defaults_dict: dict[str, str | float | int | bool | None] = {} + 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 - - for sass_var in color_map[thm_name]: - mapped[sass_var] = thm_color + defaults_dict[f"brand_color_{thm_name}"] = 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_defaults = [ + "", + "// *---- brand.color.palette ----* //", + *Theme._combine_args_kwargs(kwargs=palette_defaults_dict, is_default=True), + ] + + defaults = [ + "", + "// *---- brand.color ----* //", + *Theme._combine_args_kwargs(kwargs=defaults_dict, is_default=True), + ] + + palette_rules = [ + "", + "// *---- brand.color.palette ----* //", + ":root {", + *palette_css_vars, + "}", + ] + + return ( + "\n".join(palette_defaults), # brand.color.palette:defaults + "\n".join(defaults), # brand.color:defaults + "\n".join(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") -> str: + """ + 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, str | float | int | bool | None] = {} if not brand.typography: - return mapped + return "" brand_typography = brand.typography.model_dump( exclude={"fonts"}, @@ -368,159 +331,19 @@ 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}") - - return mapped - - 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, "}") + field = field.replace("-", "_") + prop_key = prop_key.replace("-", "_") + typo_sass_var = f"brand_typography_{field}_{prop_key}" + mapped[typo_sass_var] = prop_value + + ret = [ + "", + "// *---- brand.typography ----* //", + *Theme._combine_args_kwargs(kwargs=mapped, is_default=True), + ] + + return "\n".join(ret) def _add_brand_bootstrap_other(self, bootstrap: BrandBootstrapConfig): if bootstrap.functions: 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..81d6ff571 --- /dev/null +++ b/shiny/www/py-shiny/brand/_brand-yml.scss @@ -0,0 +1,213 @@ +/*-- 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 --*/ + +/*-- insert(brand.color.palette:defaults) --*/ +/*-- insert(brand.defaults:defaults) --*/ +/*-- insert(brand.color:defaults) --*/ +/*-- insert(brand.typography:defaults) --*/ + +//*-- 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 --*// +$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; +$primary: $brand_color_primary !default; +$secondary: $brand_color_secondary !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; +$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; + +//*-- 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 --*/ +/*-- insert(brand.color.palette: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); + } +} From efcb11f47b1f057c05f1b3936dbc4870bed27f2c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 4 Dec 2024 13:34:51 -0500 Subject: [PATCH 02/11] fix: Can't use `null !default` trick for Bootstrap core vars like `$primary`, `$secondary`, etc. --- shiny/ui/_theme_brand.py | 14 ++++++++++++-- shiny/www/py-shiny/brand/_brand-yml.scss | 8 -------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 74bfc3dff..968c37373 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -262,9 +262,19 @@ def _prepare_color_vars( palette_defaults_dict: dict[str, str | float | int | bool | None] = {} 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(): - defaults_dict[f"brand_color_{thm_name}"] = thm_color + # Create brand Sass variables and set related Bootstrap Sass vars + # brand.color.primary = "#007bff" + # ==> $brand_color_primary: #007bff !default; + # ==> $primary: $brand_color_primary !default; + + brand_color_var = f"brand_color_{thm_name}" + defaults_dict[brand_color_var] = thm_color + # Currently, brand.color fields are directly named after Bootstrap vars. If + # that changes, we'd need to use a map here. These values can't be set to + # `null !default` because they're used by maps in the Bootstrap mixins layer + # and cause errors if a color is `null` rather than non-existent. + defaults_dict[thm_name] = f"${brand_color_var}" brand_color_palette = brand.color.to_dict(include="palette") diff --git a/shiny/www/py-shiny/brand/_brand-yml.scss b/shiny/www/py-shiny/brand/_brand-yml.scss index 81d6ff571..5707c6f3f 100644 --- a/shiny/www/py-shiny/brand/_brand-yml.scss +++ b/shiny/www/py-shiny/brand/_brand-yml.scss @@ -72,18 +72,10 @@ $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; -$primary: $brand_color_primary !default; -$secondary: $brand_color_secondary !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; -$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; //*-- brand.typography --*// // brand.typography.base From a32be5ff1079eedd495ddb72d85bcbad5f69c506 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 4 Dec 2024 15:21:23 -0500 Subject: [PATCH 03/11] tests: Test that brand-backed themes compile without error --- tests/pytest/test_theme.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index 95b5f2850..d529f2dfb 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -257,3 +257,33 @@ 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) From 39e639037e62cba7ab755ad498f2c73878114c07 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 4 Dec 2024 15:24:11 -0500 Subject: [PATCH 04/11] chore: make format --- tests/pytest/test_theme.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index d529f2dfb..dfd87202f 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -258,6 +258,7 @@ def test_theme_add_sass_layer_file(): 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): From 4ddf0f51451f98529b8da4785972e587e24537ee Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 4 Dec 2024 17:18:28 -0500 Subject: [PATCH 05/11] refactor: Use `add` methods for brand Sass inclusion Removes the `_insert_sass()` method to use standard `ui.Theme()` methods --- shiny/ui/_theme_brand.py | 112 ++++++++++------------- shiny/www/py-shiny/brand/_brand-yml.scss | 10 +- 2 files changed, 52 insertions(+), 70 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 968c37373..384e0ae6d 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -52,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 @@ -183,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) @@ -208,17 +206,25 @@ def __init__( brand_typography_defaults = ThemeBrand._prepare_typography_vars(brand) - brand_bootstrap_defaults = ( - "\n".join(Theme._combine_args_kwargs(kwargs=brand_bootstrap.defaults)) - if brand_bootstrap.defaults - else "" - ) + # 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("defaults (bootstrap)", **(brand_bootstrap.defaults)) + + self._add_defaults_hdr("color.palette", **brand_color_palette_defaults) - self._insert_sass("brand.color.palette:defaults", brand_color_palette_defaults) - self._insert_sass("brand.defaults:defaults", brand_bootstrap_defaults) - self._insert_sass("brand.color:defaults", brand_color_defaults) - self._insert_sass("brand.typography:defaults", brand_typography_defaults) - self._insert_sass("brand.color.palette:rules", brand_color_palette_rules) + # Rules ---- + self.add_rules(*brand_color_palette_rules) + + # Bootstrap extras: functions, mixins, rules (defaults handled above) self._add_brand_bootstrap_other(brand_bootstrap) def _get_theme_name(self, brand: "Brand") -> str: @@ -227,39 +233,18 @@ def _get_theme_name(self, brand: "Brand") -> str: return brand.meta.name.short or brand.meta.name.full or "brand" - def _insert_sass(self, name: str, code: str): - name_parts = name.split(":") - if len(name_parts) != 2: - raise ValueError( - f"Invalid name format. Expected 'name:layer', got '{name}'" - ) - - layer = name_parts[1] - layer_attr = f"_{layer}" - if not hasattr(self, layer_attr): - raise ValueError(f"Invalid layer: {layer}") - - layer_content = getattr(self, layer_attr) - insert_marker = f"/*-- insert({name}) --*/" - - new_layer_content = [ - chunk.replace(insert_marker, code) for chunk in layer_content - ] - - setattr(self, layer_attr, new_layer_content) - @staticmethod def _prepare_color_vars( brand: "Brand", - ) -> tuple[str, str, str]: + ) -> tuple[dict[str, YamlScalarType], dict[str, YamlScalarType], list[str]]: """ Colors: Create a dictionaries of Sass and CSS variables """ if not brand.color: - return "", "", "" + return {}, {}, [] - defaults_dict: dict[str, str | float | int | bool | None] = {} - palette_defaults_dict: dict[str, str | float | int | bool | None] = {} + defaults_dict: dict[str, YamlScalarType] = {} + palette_defaults_dict: dict[str, YamlScalarType] = {} palette_css_vars: list[str] = [] for thm_name, thm_color in brand.color.to_dict(include="theme").items(): @@ -289,20 +274,7 @@ def _prepare_color_vars( # => CSS var: `--brand-{name}: {value}` palette_css_vars.append(f" --brand-{pal_name}: {pal_color};") - palette_defaults = [ - "", - "// *---- brand.color.palette ----* //", - *Theme._combine_args_kwargs(kwargs=palette_defaults_dict, is_default=True), - ] - - defaults = [ - "", - "// *---- brand.color ----* //", - *Theme._combine_args_kwargs(kwargs=defaults_dict, is_default=True), - ] - palette_rules = [ - "", "// *---- brand.color.palette ----* //", ":root {", *palette_css_vars, @@ -310,13 +282,13 @@ def _prepare_color_vars( ] return ( - "\n".join(palette_defaults), # brand.color.palette:defaults - "\n".join(defaults), # brand.color:defaults - "\n".join(palette_rules), # brand.color.palette:rules + 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") -> str: + def _prepare_typography_vars(brand: "Brand") -> dict[str, YamlScalarType]: """ Typography: Create a list of brand Sass variables @@ -329,10 +301,10 @@ def _prepare_typography_vars(brand: "Brand") -> str: $brand_typography_base_line-height: 1.25; ``` """ - mapped: dict[str, str | float | int | bool | None] = {} + mapped: dict[str, YamlScalarType] = {} if not brand.typography: - return "" + return {} brand_typography = brand.typography.model_dump( exclude={"fonts"}, @@ -347,21 +319,31 @@ def _prepare_typography_vars(brand: "Brand") -> str: typo_sass_var = f"brand_typography_{field}_{prop_key}" mapped[typo_sass_var] = prop_value - ret = [ - "", - "// *---- brand.typography ----* //", - *Theme._combine_args_kwargs(kwargs=mapped, is_default=True), - ] + return mapped - return "\n".join(ret) + def _add_defaults_hdr(self, header: str, **kwargs: YamlScalarType): + self.add_defaults(**kwargs) + self.add_defaults(f"\n// *---- brand: {header} ----* //") 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 index 5707c6f3f..c4c66d0e1 100644 --- a/shiny/www/py-shiny/brand/_brand-yml.scss +++ b/shiny/www/py-shiny/brand/_brand-yml.scss @@ -22,10 +22,11 @@ /*-- scss:defaults --*/ -/*-- insert(brand.color.palette:defaults) --*/ -/*-- insert(brand.defaults:defaults) --*/ -/*-- insert(brand.color:defaults) --*/ -/*-- insert(brand.typography:defaults) --*/ +// Sass variables from `brand` will be inserted (above) here in this order: +// * brand.color.palette +// * brand.defaults +// * brand.color +// * brand.typography //*-- brand: initial defaults --*// $brand_color_foreground: null !default; @@ -166,7 +167,6 @@ $link-bg: null !default; $link-weight: null !default; /*-- scss:rules --*/ -/*-- insert(brand.color.palette:rules) --*/ // *---- brand: brand rules to augment Bootstrap rules ----* // // https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_root.scss#L82 From b17d04f290230c29387ec762df9aa584cc25dbba Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 4 Dec 2024 17:21:01 -0500 Subject: [PATCH 06/11] chore: small diff reduction --- shiny/ui/_theme_brand.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 384e0ae6d..2df1e8f0a 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -304,7 +304,7 @@ def _prepare_typography_vars(brand: "Brand") -> dict[str, YamlScalarType]: mapped: dict[str, YamlScalarType] = {} if not brand.typography: - return {} + return mapped brand_typography = brand.typography.model_dump( exclude={"fonts"}, From 01d6e8bee53de95d9fdb5e2bf2c88b8926b4adb6 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 10 Dec 2024 17:21:55 -0500 Subject: [PATCH 07/11] chore: keep `-` in field names in brand Sass vars --- shiny/ui/_theme_brand.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 2df1e8f0a..f7d6f9665 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -314,8 +314,6 @@ def _prepare_typography_vars(brand: "Brand") -> dict[str, YamlScalarType]: for field, prop in brand_typography.items(): for prop_key, prop_value in prop.items(): - field = field.replace("-", "_") - prop_key = prop_key.replace("-", "_") typo_sass_var = f"brand_typography_{field}_{prop_key}" mapped[typo_sass_var] = prop_value From 85833f415c091ed04fc8ab4d0267075c605fad11 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 10 Dec 2024 17:24:02 -0500 Subject: [PATCH 08/11] chore: add clarifying comment Co-authored-by: Carson Sievert --- shiny/www/py-shiny/brand/_brand-yml.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/www/py-shiny/brand/_brand-yml.scss b/shiny/www/py-shiny/brand/_brand-yml.scss index c4c66d0e1..abd420b97 100644 --- a/shiny/www/py-shiny/brand/_brand-yml.scss +++ b/shiny/www/py-shiny/brand/_brand-yml.scss @@ -24,7 +24,7 @@ // Sass variables from `brand` will be inserted (above) here in this order: // * brand.color.palette -// * brand.defaults +// * brand.defaults (Brand-defined Bootstrap defaults) // * brand.color // * brand.typography From 7c67f6176039f41be856b38897483c03a14190b0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 11 Dec 2024 14:45:10 -0500 Subject: [PATCH 09/11] fix: Work around bslib's `$default` for BS3-4 compat --- shiny/ui/_theme_brand.py | 5 ----- shiny/www/py-shiny/brand/_brand-yml.scss | 10 ++++++++++ .../www/shared/sass/bslib/lib/bs5/scss/_variables.scss | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index f7d6f9665..d9c6f77df 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -255,11 +255,6 @@ def _prepare_color_vars( brand_color_var = f"brand_color_{thm_name}" defaults_dict[brand_color_var] = thm_color - # Currently, brand.color fields are directly named after Bootstrap vars. If - # that changes, we'd need to use a map here. These values can't be set to - # `null !default` because they're used by maps in the Bootstrap mixins layer - # and cause errors if a color is `null` rather than non-existent. - defaults_dict[thm_name] = f"${brand_color_var}" brand_color_palette = brand.color.to_dict(include="palette") diff --git a/shiny/www/py-shiny/brand/_brand-yml.scss b/shiny/www/py-shiny/brand/_brand-yml.scss index abd420b97..de6a1f6f4 100644 --- a/shiny/www/py-shiny/brand/_brand-yml.scss +++ b/shiny/www/py-shiny/brand/_brand-yml.scss @@ -69,6 +69,16 @@ $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; 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; From 6eaf92653d6c7067b58076aa86a657b1335841be Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 31 Dec 2024 11:30:09 -0500 Subject: [PATCH 10/11] chore: remove unmapped brand sass var check This isn't used anymore, we now create brand sass vars for everything --- examples/brand/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/brand/app.py b/examples/brand/app.py index 3f45e0645..01f140c02 100644 --- a/examples/brand/app.py +++ b/examples/brand/app.py @@ -7,8 +7,6 @@ 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.add_rules((Path(__file__).parent / "_colors.scss").read_text()) From 24cbfdab069f8cefbe13a84210be974668e9d7f9 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 31 Dec 2024 11:36:47 -0500 Subject: [PATCH 11/11] fix: remove unused import --- examples/brand/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/brand/app.py b/examples/brand/app.py index 01f140c02..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 @@ -8,7 +7,7 @@ from shiny.ui._theme_brand import bootstrap_colors 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(