From f0b34644f93f205797e97ad92b424e9566b5d95b Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 21 Oct 2024 16:29:58 -0400 Subject: [PATCH 01/82] feat: initial BrandTheme --- pyproject.toml | 5 +- shiny/ui/_theme_brand.py | 149 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 shiny/ui/_theme_brand.py diff --git a/pyproject.toml b/pyproject.toml index b5a1d7f13..f3b597dfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,10 @@ dependencies = [ ] [project.optional-dependencies] -theme = ["libsass>=0.23.0"] +theme = [ + "libsass>=0.23.0", + "brand_yml" +] test = [ "pytest>=6.2.4", "pytest-asyncio>=0.17.2", diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py new file mode 100644 index 000000000..cbe419a45 --- /dev/null +++ b/shiny/ui/_theme_brand.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from brand_yml import Brand, read_brand_yml + +from ._theme import Theme + +typography_map = { + "base": { + "family": "font-family-base", + "size": "font-size-base", + "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", + }, + "monospace_inline": { + "family": "font-family-monospace-inline", + "color": "code-color", + "background_color": "code-bg", + "size": "code-inline-font-size", + "weight": "code-font-weight", + }, + "monospace_block": { + "family": "font-family-monospace-block", + "line_height": "pre-line-height", + "color": "pre-color", + "background_color": "pre-bg", + }, + "link": { + "background_color": "link-bg", + "color": "link-color", + "weight": "link-weight", + "decoration": "link-decoration", + }, +} + +color_extras_map = { + "foreground": ["body-color", "pre-color"], + "background": ["body-bg"], + "secondary": ["body-secondary-color", "body-secondary"], + "tertiary": ["body-tertiary-color", "body-tertiary"], +} + + +class BrandTheme(Brand): + def __init__(self, brand_yml: str | Path | None = None, *args: Any, **kwargs: Any): + if brand_yml is not None and len(args) == 0: + if len(kwargs) > 0: + raise ValueError("Cannot pass both `brand_yml` and `**kwargs`") + data: dict[str, Any] = read_brand_yml(brand_yml, as_data=True) + super().__init__(**data) + return + if brand_yml is not None and len(args) > 0: + args = (brand_yml, *args) + + super().__init__(*args, **kwargs) + + @classmethod + def from_brand(cls, brand: Brand): + return cls.model_validate(brand.model_dump()) + + def theme(self) -> Theme: + colors: dict[str, str] = {} + if self.color: + colors: dict[str, str] = { + k: v + for k, v in self.color.model_dump(exclude_none=True).items() + if k != "palette" + } + + for extra, sass_var_list in color_extras_map.items(): + if extra in colors: + sass_vars = {var: colors[extra] for var in sass_var_list} + colors = {**colors, **sass_vars} + + typography: dict[str, str] = {} + if self.typography: + for field in self.typography.model_fields.keys(): + if field == "fonts": + continue + type_prop = getattr(self.typography, field) + if type_prop is None: + continue + for k, v in type_prop.model_dump(exclude_none=True).items(): + if k in typography_map[field]: + typography[typography_map[field][k]] = v + else: + print(f"skipping {field}.{k} not mapped") + + brand_colors_sass_vars: dict[str, str] = {} + brand_colors_css_vars: list[str] = [] + + if self.color and self.color.palette is not None: + brand_colors_sass_vars.update( + {f"brand-{k}": v for k, v in self.color.palette.items()} + ) + + for k, v in self.color.palette.items(): + brand_colors_css_vars.append(f"--brand-{k}: {v};") + + sass_vars: dict[str, str] = {**brand_colors_sass_vars, **colors, **typography} + sass_vars = {k: v for k, v in sass_vars.items()} + + name: str = "brand" + if self.meta and self.meta.name: + name = self.meta.name.full or self.meta.name.short or "brand" + + return ( + Theme(name=name) + .add_defaults( + **{ + "code-font-weight": "normal", + "link-bg": None, + "link-weight": None, + } + ) + .add_defaults(**sass_vars) + .add_defaults( + self.typography.css_include_fonts() if self.typography else "" + ) + .add_rules(":root {", *brand_colors_css_vars, "}") + .add_rules( + """ + // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_root.scss#L82 + :root { + --#{$prefix}link-bg: #{$link-bg}; + --#{$prefix}link-weight: #{$link-weight}; + } + // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_reboot.scss#L244 + a { + background-color: var(--#{$prefix}link-bg); + font-weight: var(--#{$prefix}link-weight); + } + """ + ) + .add_rules("code { font-weight: $code-font-weight; }") + ) From 730af5cb73faae846d27787a3f0cf43fc5253a1d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 21 Oct 2024 17:29:38 -0400 Subject: [PATCH 02/82] feat: rename ThemeBrand --- shiny/ui/__init__.py | 2 ++ shiny/ui/_theme_brand.py | 28 +++++++++++++++------------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index b674cfeb3..c0917181f 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -155,6 +155,7 @@ update_sidebar, ) from ._theme import Theme +from ._theme_brand import ThemeBrand from ._tooltip import tooltip from ._utils import js_eval from ._valuebox import ( @@ -324,6 +325,7 @@ "ShowcaseLayout", # _theme "Theme", + "ThemeBrand", # _tooltip "tooltip", # _progress diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index cbe419a45..39b0e392a 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -54,7 +54,7 @@ } -class BrandTheme(Brand): +class ThemeBrand(Brand): def __init__(self, brand_yml: str | Path | None = None, *args: Any, **kwargs: Any): if brand_yml is not None and len(args) == 0: if len(kwargs) > 0: @@ -133,17 +133,19 @@ def theme(self) -> Theme: .add_rules(":root {", *brand_colors_css_vars, "}") .add_rules( """ - // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_root.scss#L82 - :root { - --#{$prefix}link-bg: #{$link-bg}; - --#{$prefix}link-weight: #{$link-weight}; - } - // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_reboot.scss#L244 - a { - background-color: var(--#{$prefix}link-bg); - font-weight: var(--#{$prefix}link-weight); - } - """ + // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_root.scss#L82 + :root { + --#{$prefix}link-bg: #{$link-bg}; + --#{$prefix}link-weight: #{$link-weight}; + } + // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_reboot.scss#L244 + a { + background-color: var(--#{$prefix}link-bg); + font-weight: var(--#{$prefix}link-weight); + } + code { + font-weight: $code-font-weight; + } + """ ) - .add_rules("code { font-weight: $code-font-weight; }") ) From b73470ff2386eb36c2c6e54490857109516c2999 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Mon, 21 Oct 2024 17:30:02 -0400 Subject: [PATCH 03/82] feat: Add `_brand*.yml` to default reload includes --- shiny/_main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shiny/_main.py b/shiny/_main.py index e004f4312..dd9987fc6 100644 --- a/shiny/_main.py +++ b/shiny/_main.py @@ -41,6 +41,7 @@ def main() -> None: "*.htm", "*.html", "*.png", + "_brand*.yml", ) RELOAD_EXCLUDES_DEFAULT = (".*", "*.py[cod]", "__pycache__", "env", "venv") From ef936ea7c18db46998d72d1d0cee2e3e69a7ad6c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 22 Oct 2024 10:24:54 -0400 Subject: [PATCH 04/82] feat(Theme): Add `from_brand()` method --- shiny/ui/__init__.py | 2 - shiny/ui/_theme.py | 25 ++- shiny/ui/_theme_brand.py | 332 ++++++++++++++++++++++++++----------- tests/pytest/test_theme.py | 4 +- 4 files changed, 259 insertions(+), 104 deletions(-) diff --git a/shiny/ui/__init__.py b/shiny/ui/__init__.py index c0917181f..b674cfeb3 100644 --- a/shiny/ui/__init__.py +++ b/shiny/ui/__init__.py @@ -155,7 +155,6 @@ update_sidebar, ) from ._theme import Theme -from ._theme_brand import ThemeBrand from ._tooltip import tooltip from ._utils import js_eval from ._valuebox import ( @@ -325,7 +324,6 @@ "ShowcaseLayout", # _theme "Theme", - "ThemeBrand", # _tooltip "tooltip", # _progress diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index 27c7e772f..f7e85964f 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -7,6 +7,7 @@ import textwrap from typing import Any, Literal, Optional, Sequence, TypeVar +from brand_yml import Brand from htmltools import HTMLDependency from .._docstring import add_example @@ -460,25 +461,29 @@ def _dep_create(self, css_path: str | pathlib.Path) -> HTMLDependency: "href": css_path.name, "data-shiny-theme": self.name or self._preset, # type: ignore }, + # Branded themes re-use this tempdir + all_files=False, ) def _html_dependency_precompiled(self) -> HTMLDependency: return self._dep_create(css_path=self._dep_css_precompiled_path()) - def _html_dependency(self) -> HTMLDependency: + def _html_dependency(self) -> list[HTMLDependency]: """ Create an `HTMLDependency` object from the theme. Returns ------- : - An :class:`~htmltools.HTMLDependency` object representing the theme. In - most cases, you should not need to call this method directly. Instead, pass - the `Theme` object directly to the `theme` argument of any Shiny page - function. + An list of :class:`~htmltools.HTMLDependency` objects representing the + theme. In most cases, you should not need to call this method directly. + Instead, pass the `Theme` object directly to the `theme` argument of any + Shiny page function. """ + # Note: return a list so that this method can be overridden in subclasses of + # Theme that want to attach additional dependencies to the theme dependency. if self._can_use_precompiled(): - return self._html_dependency_precompiled() + return [self._html_dependency_precompiled()] css_name = self._dep_css_name() css_path = os.path.join(self._get_css_tempdir(), css_name) @@ -487,7 +492,7 @@ def _html_dependency(self) -> HTMLDependency: with open(css_path, "w") as css_file: css_file.write(self.to_css()) - return self._dep_create(css_path) + return [self._dep_create(css_path)] def tagify(self) -> None: raise SyntaxError( @@ -497,6 +502,12 @@ def tagify(self) -> None: "or any `shiny.ui.page_*()` function (Shiny Core)." ) + @classmethod + def from_brand(cls, brand: str | pathlib.Path | Brand): + from ._theme_brand import theme_from_brand # avoid circular import + + return theme_from_brand(brand) + def path_pkg_preset(preset: ShinyThemePreset, *args: str) -> str: """ diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 39b0e392a..154ff60cc 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -1,11 +1,48 @@ from __future__ import annotations +import warnings from pathlib import Path -from typing import Any +from shutil import copyfile +from typing import Any, Optional -from brand_yml import Brand, read_brand_yml +from brand_yml import Brand, FileLocationLocal +from htmltools import HTMLDependency +from .._versions import bootstrap from ._theme import Theme +from ._theme_presets import ShinyThemePreset, shiny_theme_presets + +color_extras_map = { + "foreground": ["body-color", "pre-color"], + "background": ["body-bg"], + "secondary": ["body-secondary-color", "body-secondary"], + "tertiary": ["body-tertiary-color", "body-tertiary"], +} +"""Maps brand.color fields to Bootstrap Sass variables""" + +bootstrap_colors = { + "5": [ + "blue", + "indigo", + "purple", + "pink", + "red", + "orange", + "yellow", + "green", + "teal", + "cyan", + "black", + "white", + "gray", + "gray-dark", + ] +} +""" +Colors known to Bootstrap + +* [Bootstrap 5 - Colors](https://getbootstrap.com/docs/5.3/customize/color/#color-sass-maps) +""" typography_map = { "base": { @@ -45,107 +82,216 @@ "decoration": "link-decoration", }, } +"""Maps brand.typography fields to corresponding Bootstrap Sass variables""" -color_extras_map = { - "foreground": ["body-color", "pre-color"], - "background": ["body-bg"], - "secondary": ["body-secondary-color", "body-secondary"], - "tertiary": ["body-tertiary-color", "body-tertiary"], -} +class BrandBootstrap: + """Convenience class for storing Bootstrap defaults from a brand instance""" + + def __init__( + self, + version: Any = bootstrap, + preset: Any = "shiny", + **kwargs: str | int | bool | float | None, + ): + if not isinstance(version, (str, int)): + raise ValueError( + f"Bootstrap version must be a string or integer, not {version!r}." + ) -class ThemeBrand(Brand): - def __init__(self, brand_yml: str | Path | None = None, *args: Any, **kwargs: Any): - if brand_yml is not None and len(args) == 0: - if len(kwargs) > 0: - raise ValueError("Cannot pass both `brand_yml` and `**kwargs`") - data: dict[str, Any] = read_brand_yml(brand_yml, as_data=True) - super().__init__(**data) - return - if brand_yml is not None and len(args) > 0: - args = (brand_yml, *args) + if version != bootstrap: + warnings.warn( + f"Shiny does not current support Bootstrap version {version}. " + f"Using Bootstrap v{bootstrap} instead.", + stacklevel=4, + ) + version = bootstrap + + if not isinstance(preset, str) or preset not in shiny_theme_presets: + raise ValueError( + f"{preset!r} is not a valid Bootstrap preset provided by Shiny. " + f"Valid presets are {shiny_theme_presets}." + ) - super().__init__(*args, **kwargs) + self.version: str = str(version) + self.preset: ShinyThemePreset = preset + self.defaults = kwargs @classmethod def from_brand(cls, brand: Brand): - return cls.model_validate(brand.model_dump()) - - def theme(self) -> Theme: - colors: dict[str, str] = {} - if self.color: - colors: dict[str, str] = { - k: v - for k, v in self.color.model_dump(exclude_none=True).items() - if k != "palette" - } + defaults: dict[str, str | int | bool | float | None] = {} - for extra, sass_var_list in color_extras_map.items(): - if extra in colors: - sass_vars = {var: colors[extra] for var in sass_var_list} - colors = {**colors, **sass_vars} - - typography: dict[str, str] = {} - if self.typography: - for field in self.typography.model_fields.keys(): - if field == "fonts": - continue - type_prop = getattr(self.typography, field) - if type_prop is None: - continue - for k, v in type_prop.model_dump(exclude_none=True).items(): - if k in typography_map[field]: - typography[typography_map[field][k]] = v - else: - print(f"skipping {field}.{k} not mapped") - - brand_colors_sass_vars: dict[str, str] = {} - brand_colors_css_vars: list[str] = [] - - if self.color and self.color.palette is not None: - brand_colors_sass_vars.update( - {f"brand-{k}": v for k, v in self.color.palette.items()} - ) + if brand.defaults: + if brand.defaults and "bootstrap" in brand.defaults: + defaults.update(brand.defaults["bootstrap"]) + if "shiny" in brand.defaults and "theme" in brand.defaults["shiny"]: + defaults.update(brand.defaults["shiny"]["theme"]) - for k, v in self.color.palette.items(): - brand_colors_css_vars.append(f"--brand-{k}: {v};") + return cls(**defaults) - sass_vars: dict[str, str] = {**brand_colors_sass_vars, **colors, **typography} - sass_vars = {k: v for k, v in sass_vars.items()} - name: str = "brand" - if self.meta and self.meta.name: - name = self.meta.name.full or self.meta.name.short or "brand" +class ThemeBrand(Theme): + def __init__( + self, + brand: Brand, + preset: ShinyThemePreset = "shiny", + name: Optional[str] = None, + include_paths: Optional[str | Path | list[str | Path]] = None, + ): + super().__init__(preset=preset, name=name, include_paths=include_paths) + self.brand = brand - return ( - Theme(name=name) - .add_defaults( - **{ - "code-font-weight": "normal", - "link-bg": None, - "link-weight": None, - } - ) - .add_defaults(**sass_vars) - .add_defaults( - self.typography.css_include_fonts() if self.typography else "" - ) - .add_rules(":root {", *brand_colors_css_vars, "}") - .add_rules( - """ - // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_root.scss#L82 - :root { - --#{$prefix}link-bg: #{$link-bg}; - --#{$prefix}link-weight: #{$link-weight}; - } - // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_reboot.scss#L244 - a { - background-color: var(--#{$prefix}link-bg); - font-weight: var(--#{$prefix}link-weight); - } - code { - font-weight: $code-font-weight; - } - """ - ) + def _html_dependency(self) -> list[HTMLDependency]: + theme_dep = super()._html_dependency() + + if not self.brand.typography: + return theme_dep + + # We're going to put the fonts dependency _inside_ the theme's tempdir, which + # relies on the theme's dependency having `all_files=True`. + temp_dir = self._get_css_tempdir() + temp_path = Path(temp_dir) / "fonts" + temp_path.mkdir(parents=True, exist_ok=True) + + # Write fonts.css from typography.css_include_fonts() + fonts_css_path = temp_path / "fonts.css" + fonts_css_path.write_text(self.brand.typography.css_include_fonts()) + + # Copy local files from typography.fonts into the temp dir + for font in self.brand.typography.fonts: + if isinstance(font, FileLocationLocal): + dest_path = temp_path / font.relative() + dest_path.parent.mkdir(parents=True, exist_ok=True) + copyfile(font.absolute(), dest_path) + + # Create an HTMLDependency for font.css + font_dep = HTMLDependency( + name=f"{self._dep_name()}-typography", + version=self._version, + source={"subdir": str(temp_path)}, + stylesheet={"href": "fonts.css"}, + all_files=True, + ) + + return [*theme_dep, font_dep] + + +def theme_from_brand(brand: str | Path | Brand) -> Theme: + """ + Create a custom Shiny theme from a `_brand.yml` + + Creates a custom Shiny theme for your brand using + [brand.yml](https://posit-dev.github.io/brand-yml), which may be either an instance + of :class:`brand_yml.Brand` or a :class:`Path` used by + :meth:`brand_yml.Brand.from_yaml` to locate the `_brand.yml` file. + + Parameters + ---------- + brand + A :class:`brand_yml.Brand` instance, or a path to help locate `_brand.yml`. + For a path, you can pass `__file__` or a directory containing the `_brand.yml` + or a path directly to the `_brand.yml` file. + + Returns + ------- + : + A :class:`shiny.ui.Theme` instance with a custom Shiny theme created from the + brand guidelines (see :class:`brand_yml.Brand`). + """ + if not isinstance(brand, Brand): + brand = Brand.from_yaml(brand) + + if not isinstance(brand, Brand): + raise ValueError("Invalid `brand`, must be a path or a Brand instance.") + + brand_bootstrap = BrandBootstrap.from_brand(brand) + + colors: dict[str, str] = {} + if brand.color: + colors: dict[str, str] = { + k: v + for k, v in brand.color.model_dump(exclude_none=True).items() + if k != "palette" + } + + for extra, sass_var_list in color_extras_map.items(): + if extra in colors: + brand_sass_vars = {var: colors[extra] for var in sass_var_list} + colors = {**colors, **brand_sass_vars} + + typography: dict[str, str] = {} + if brand.typography: + for field in brand.typography.model_fields.keys(): + if field == "fonts": + continue + type_prop = getattr(brand.typography, field) + if type_prop is None: + continue + for k, v in type_prop.model_dump(exclude_none=True).items(): + if k in typography_map[field]: + typography[typography_map[field][k]] = v + else: + # TODO: Need to catch these and map to appropriate Bootstrap vars + print(f"skipping {field}.{k} not mapped") + + brand_colors_sass_vars: dict[str, str] = {} + brand_colors_css_vars: list[str] = [] + + if brand.color and brand.color.palette is not None: + brand_colors_sass_vars.update( + {f"brand-{k}": v for k, v in brand.color.palette.items()} + ) + + for k, v in brand.color.palette.items(): + brand_colors_css_vars.append(f"--brand-{k}: {v};") + + brand_sass_vars: dict[str, str] = {**brand_colors_sass_vars, **colors, **typography} + brand_sass_vars = {k: v for k, v in brand_sass_vars.items()} + + name: str = "brand" + if brand.meta and brand.meta.name: + name = brand.meta.name.full or brand.meta.name.short or "brand" + + return ( + ThemeBrand( + brand, + name=name, + preset=brand_bootstrap.preset, + ) + # 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 Sass vars (colors, typography) + # 2. Brand Bootstrap Sass vars + # 3. Fallback vars needed by additional Brand rules + .add_defaults( + # Variables we create to augment Bootstrap's variables + **{ + "code-font-weight": "normal", + "link-bg": None, + "link-weight": None, + } + ) + .add_defaults(**brand_bootstrap.defaults) + .add_defaults(**brand_sass_vars) + .add_defaults(brand.typography.css_include_fonts() if brand.typography else "") + # Brand Rules ---- + .add_rules(":root {", *brand_colors_css_vars, "}") + # Additional rules to fill in Bootstrap styles for Brand parameters + .add_rules( + """ + // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_root.scss#L82 + :root { + --#{$prefix}link-bg: #{$link-bg}; + --#{$prefix}link-weight: #{$link-weight}; + } + // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_reboot.scss#L244 + a { + background-color: var(--#{$prefix}link-bg); + font-weight: var(--#{$prefix}link-weight); + } + code { + font-weight: $code-font-weight; + } + """ ) + ) diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index 1bfc42b87..f4d8d39de 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -214,7 +214,7 @@ def test_theme_dep_name_is_valid_path_part(): def test_theme_dependency_has_data_attribute(): theme = Theme("shiny") - assert theme._html_dependency().stylesheet[0]["data-shiny-theme"] == "shiny" # type: ignore + assert theme._html_dependency()[0].stylesheet[0]["data-shiny-theme"] == "shiny" # type: ignore theme = Theme("shiny", name="My Fancy Theme") - assert theme._html_dependency().stylesheet[0]["data-shiny-theme"] == "My Fancy Theme" # type: ignore + assert theme._html_dependency()[0].stylesheet[0]["data-shiny-theme"] == "My Fancy Theme" # type: ignore From 9ebf4a558fa05aab62a2f5f0ffa59850b1f87704 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 22 Oct 2024 10:25:04 -0400 Subject: [PATCH 05/82] docs: Add brand-yml to interlinks inventories --- docs/_quarto.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 18c79c02d..4aa2c6d63 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -44,3 +44,5 @@ interlinks: url: https://matplotlib.org/stable/ python: url: https://docs.python.org/3/ + brand-yml: + url: https://posit-dev.github.io/brand-yml/ From 9fe971472a8a44f22c692d378aa0047e02d7787e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 22 Oct 2024 13:21:32 -0400 Subject: [PATCH 06/82] chore: add brand_yml to dev dependencies too --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f3b597dfe..db3c92a3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ [project.optional-dependencies] theme = [ "libsass>=0.23.0", - "brand_yml" + "brand_yml>=0.1.0rc5" ] test = [ "pytest>=6.2.4", @@ -103,6 +103,7 @@ dev = [ "Flake8-pyproject>=1.2.3", "isort>=5.10.1", "libsass>=0.23.0", + "brand_yml>=0.1.0rc5", "pyright>=1.1.383", "pre-commit>=2.15.0", "wheel", From 3943ebc516b9e8a3fa2b731eda90f6ded043425e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 22 Oct 2024 14:45:21 -0400 Subject: [PATCH 07/82] chore(Theme): Rename method `_html_dependencies` --- shiny/ui/_html_deps_external.py | 2 +- shiny/ui/_theme.py | 2 +- shiny/ui/_theme_brand.py | 8 +++++--- tests/pytest/test_theme.py | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/shiny/ui/_html_deps_external.py b/shiny/ui/_html_deps_external.py index 5205e6313..00e40c723 100644 --- a/shiny/ui/_html_deps_external.py +++ b/shiny/ui/_html_deps_external.py @@ -32,7 +32,7 @@ def shiny_page_theme_deps(theme: str | Path | Theme | ThemeProvider | None) -> T if theme is None: deps_theme = None elif isinstance(theme, Theme): - deps_theme = theme._html_dependency() + deps_theme = theme._html_dependencies() elif isinstance(theme, str) and theme.startswith(("http", "//")): deps_theme = head_content(link(rel="stylesheet", href=theme, type="text/css")) elif isinstance(theme, (str, Path)): diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index f7e85964f..08a7dfe5b 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -468,7 +468,7 @@ def _dep_create(self, css_path: str | pathlib.Path) -> HTMLDependency: def _html_dependency_precompiled(self) -> HTMLDependency: return self._dep_create(css_path=self._dep_css_precompiled_path()) - def _html_dependency(self) -> list[HTMLDependency]: + def _html_dependencies(self) -> list[HTMLDependency]: """ Create an `HTMLDependency` object from the theme. diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 154ff60cc..839db5e4d 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -141,8 +141,8 @@ def __init__( super().__init__(preset=preset, name=name, include_paths=include_paths) self.brand = brand - def _html_dependency(self) -> list[HTMLDependency]: - theme_dep = super()._html_dependency() + def _html_dependencies(self) -> list[HTMLDependency]: + theme_dep = super()._html_dependencies() if not self.brand.typography: return theme_dep @@ -173,7 +173,9 @@ def _html_dependency(self) -> list[HTMLDependency]: all_files=True, ) - return [*theme_dep, font_dep] + return [font_dep, *theme_dep] + + def theme_from_brand(brand: str | Path | Brand) -> Theme: diff --git a/tests/pytest/test_theme.py b/tests/pytest/test_theme.py index f4d8d39de..4a9291a2c 100644 --- a/tests/pytest/test_theme.py +++ b/tests/pytest/test_theme.py @@ -214,7 +214,7 @@ def test_theme_dep_name_is_valid_path_part(): def test_theme_dependency_has_data_attribute(): theme = Theme("shiny") - assert theme._html_dependency()[0].stylesheet[0]["data-shiny-theme"] == "shiny" # type: ignore + assert theme._html_dependencies()[0].stylesheet[0]["data-shiny-theme"] == "shiny" # type: ignore theme = Theme("shiny", name="My Fancy Theme") - assert theme._html_dependency()[0].stylesheet[0]["data-shiny-theme"] == "My Fancy Theme" # type: ignore + assert theme._html_dependencies()[0].stylesheet[0]["data-shiny-theme"] == "My Fancy Theme" # type: ignore From 669e08a57def44b61bfd4c21232f717ec70f0227 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 22 Oct 2024 21:59:36 -0400 Subject: [PATCH 08/82] feat: get fonts dependency from brand.typography --- shiny/ui/_theme_brand.py | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 839db5e4d..81352872d 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -2,10 +2,9 @@ import warnings from pathlib import Path -from shutil import copyfile from typing import Any, Optional -from brand_yml import Brand, FileLocationLocal +from brand_yml import Brand from htmltools import HTMLDependency from .._versions import bootstrap @@ -142,10 +141,10 @@ def __init__( self.brand = brand def _html_dependencies(self) -> list[HTMLDependency]: - theme_dep = super()._html_dependencies() + theme_deps = super()._html_dependencies() if not self.brand.typography: - return theme_dep + return theme_deps # We're going to put the fonts dependency _inside_ the theme's tempdir, which # relies on the theme's dependency having `all_files=True`. @@ -153,27 +152,16 @@ def _html_dependencies(self) -> list[HTMLDependency]: temp_path = Path(temp_dir) / "fonts" temp_path.mkdir(parents=True, exist_ok=True) - # Write fonts.css from typography.css_include_fonts() - fonts_css_path = temp_path / "fonts.css" - fonts_css_path.write_text(self.brand.typography.css_include_fonts()) - - # Copy local files from typography.fonts into the temp dir - for font in self.brand.typography.fonts: - if isinstance(font, FileLocationLocal): - dest_path = temp_path / font.relative() - dest_path.parent.mkdir(parents=True, exist_ok=True) - copyfile(font.absolute(), dest_path) - - # Create an HTMLDependency for font.css - font_dep = HTMLDependency( - name=f"{self._dep_name()}-typography", + fonts_dep = self.brand.typography.fonts_html_dependency( + path_dir=temp_path, + name=f"{self._dep_name()}-fonts", version=self._version, - source={"subdir": str(temp_path)}, - stylesheet={"href": "fonts.css"}, - all_files=True, ) - return [font_dep, *theme_dep] + if fonts_dep is None: + return theme_deps + + return [fonts_dep, *theme_deps] From 263ee077e8ea91a5f068dc9763b9747f7be770c0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 22 Oct 2024 22:00:54 -0400 Subject: [PATCH 09/82] chore: clean up code --- shiny/ui/_theme_brand.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 81352872d..e73510f48 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -164,8 +164,6 @@ def _html_dependencies(self) -> list[HTMLDependency]: return [fonts_dep, *theme_deps] - - def theme_from_brand(brand: str | Path | Brand) -> Theme: """ Create a custom Shiny theme from a `_brand.yml` @@ -228,10 +226,13 @@ def theme_from_brand(brand: str | Path | Brand) -> Theme: brand_colors_css_vars: list[str] = [] if brand.color and brand.color.palette is not None: + # TODO: sanitize color name into valid sass/css variable names + # Create color variables from palette, `$brand-{name}: {value}` brand_colors_sass_vars.update( {f"brand-{k}": v for k, v in brand.color.palette.items()} ) + # Create CSS variables from palette, `--brand-{name}: {value}` for k, v in brand.color.palette.items(): brand_colors_css_vars.append(f"--brand-{k}: {v};") @@ -263,18 +264,17 @@ def theme_from_brand(brand: str | Path | Brand) -> Theme: ) .add_defaults(**brand_bootstrap.defaults) .add_defaults(**brand_sass_vars) - .add_defaults(brand.typography.css_include_fonts() if brand.typography else "") # Brand Rules ---- .add_rules(":root {", *brand_colors_css_vars, "}") # Additional rules to fill in Bootstrap styles for Brand parameters .add_rules( """ - // https://github.com/twbs/bootstrap/blob/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_root.scss#L82 + // 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/5c2f2e7e0ec41daae3819106efce20e2568b19d2/scss/_reboot.scss#L244 + // https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_reboot.scss#L244 a { background-color: var(--#{$prefix}link-bg); font-weight: var(--#{$prefix}link-weight); From 603578845164b4020a9837135c002fd13c293de7 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 08:06:25 -0400 Subject: [PATCH 10/82] feat: Apply brand.color.palette to bootstrap color map --- shiny/ui/_theme_brand.py | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index e73510f48..736f6a230 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -12,15 +12,18 @@ from ._theme_presets import ShinyThemePreset, shiny_theme_presets color_extras_map = { - "foreground": ["body-color", "pre-color"], - "background": ["body-bg"], + "foreground": ["body-color", "pre-color", "black"], + "background": ["body-bg", "white"], "secondary": ["body-secondary-color", "body-secondary"], "tertiary": ["body-tertiary-color", "body-tertiary"], } """Maps brand.color fields to Bootstrap Sass variables""" bootstrap_colors = { + # https://github.com/twbs/bootstrap/blob/6e1f75/scss/_variables.scss#L38-L49 "5": [ + "white", + "black", "blue", "indigo", "purple", @@ -31,15 +34,14 @@ "green", "teal", "cyan", - "black", - "white", - "gray", - "gray-dark", ] } """ Colors known to Bootstrap +When these colors are named in `colors.palette`, we'll map the brand's colors to the +corresponding Bootstrap color Sass variable. + * [Bootstrap 5 - Colors](https://getbootstrap.com/docs/5.3/customize/color/#color-sass-maps) """ @@ -196,17 +198,27 @@ def theme_from_brand(brand: str | Path | Brand) -> Theme: colors: dict[str, str] = {} if brand.color: + # Map values in colors directly to their Sass variable counterparts colors: dict[str, str] = { k: v for k, v in brand.color.model_dump(exclude_none=True).items() - if k != "palette" + if k not in ("palette", "foreground", "background") } + # Map values in colors to any additional Sass variables for extra, sass_var_list in color_extras_map.items(): if extra in colors: brand_sass_vars = {var: colors[extra] for var in sass_var_list} colors = {**colors, **brand_sass_vars} + if brand.color.palette: + # Map the brand color palette to Bootstrap's named colors, e.g. $red, $blue. + # Note that we use ._color_defs() to ensure the palette is fully resolved. + brand_color_palette = brand.color._color_defs(resolved=True) + for bs_color_var in bootstrap_colors[brand_bootstrap.version]: + if bs_color_var in brand_color_palette: + colors[bs_color_var] = brand_color_palette[bs_color_var] + typography: dict[str, str] = {} if brand.typography: for field in brand.typography.model_fields.keys(): @@ -260,6 +272,15 @@ def theme_from_brand(brand: str | Path | Brand) -> Theme: "code-font-weight": "normal", "link-bg": None, "link-weight": None, + "gray-100": "mix($white, $black, 90%)", + "gray-200": "mix($white, $black, 80%)", + "gray-300": "mix($white, $black, 70%)", + "gray-400": "mix($white, $black, 60%)", + "gray-500": "mix($white, $black, 50%)", + "gray-600": "mix($white, $black, 40%)", + "gray-700": "mix($white, $black, 30%)", + "gray-800": "mix($white, $black, 20%)", + "gray-900": "mix($white, $black, 10%)", } ) .add_defaults(**brand_bootstrap.defaults) From 3184691e940c0f31a69e6cf37fbb26c9c6f7e241 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 08:08:09 -0400 Subject: [PATCH 11/82] fix: Use Bootstrap major version --- shiny/ui/_theme_brand.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 736f6a230..237c0fecc 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -100,13 +100,16 @@ def __init__( f"Bootstrap version must be a string or integer, not {version!r}." ) - if version != bootstrap: + v_major = str(version).split(".")[0] + bs_major = str(bootstrap).split(".")[0] + + if v_major != bs_major: warnings.warn( - f"Shiny does not current support Bootstrap version {version}. " - f"Using Bootstrap v{bootstrap} instead.", + f"Shiny does not current support Bootstrap version {v_major}. " + f"Using Bootstrap v{bs_major} instead.", stacklevel=4, ) - version = bootstrap + v_major = bs_major if not isinstance(preset, str) or preset not in shiny_theme_presets: raise ValueError( @@ -114,7 +117,7 @@ def __init__( f"Valid presets are {shiny_theme_presets}." ) - self.version: str = str(version) + self.version = v_major self.preset: ShinyThemePreset = preset self.defaults = kwargs From 137cc4c6083103cf8d603e583716aa92acd47d2c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 08:08:16 -0400 Subject: [PATCH 12/82] chore: Add todo comments --- shiny/ui/_theme_brand.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 237c0fecc..23cfd134a 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -45,6 +45,7 @@ * [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 = { "base": { "family": "font-family-base", @@ -104,6 +105,7 @@ def __init__( bs_major = str(bootstrap).split(".")[0] if v_major != bs_major: + # TODO (bootstrap-update): Assumes Shiny ships one version of Bootstrap warnings.warn( f"Shiny does not current support Bootstrap version {v_major}. " f"Using Bootstrap v{bs_major} instead.", From 949f6f1bb56e71aab1300273a24c7f1927b4a254 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 08:31:01 -0400 Subject: [PATCH 13/82] refactor: Move ThemeBrand init code into constructor ui.Theme.from_brand() now calls ThemeBrand() directly, but it handles reading the brand from YAML first if needed. --- shiny/ui/_theme.py | 28 ++++- shiny/ui/_theme_brand.py | 223 ++++++++++++++++++--------------------- 2 files changed, 129 insertions(+), 122 deletions(-) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index 08a7dfe5b..3053aaa36 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -504,9 +504,33 @@ def tagify(self) -> None: @classmethod def from_brand(cls, brand: str | pathlib.Path | Brand): - from ._theme_brand import theme_from_brand # avoid circular import + """ + Create a custom Shiny theme from a `_brand.yml` + + Creates a custom Shiny theme for your brand using + [brand.yml](https://posit-dev.github.io/brand-yml), which may be either an + instance of :class:`brand_yml.Brand` or a :class:`Path` used by + :meth:`brand_yml.Brand.from_yaml` to locate the `_brand.yml` file. + + Parameters + ---------- + brand + A :class:`brand_yml.Brand` instance, or a path to help locate `_brand.yml`. + For a path, you can pass `__file__` or a directory containing the + `_brand.yml` or a path directly to the `_brand.yml` file. + + Returns + ------- + : + A :class:`shiny.ui.Theme` instance with a custom Shiny theme created from + the brand guidelines (see :class:`brand_yml.Brand`). + """ + from ._theme_brand import ThemeBrand # avoid circular import + + if not isinstance(brand, Brand): + brand = Brand.from_yaml(brand) - return theme_from_brand(brand) + return ThemeBrand(brand) def path_pkg_preset(preset: ShinyThemePreset, *args: str) -> str: diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 23cfd134a..894919078 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -140,138 +140,97 @@ class ThemeBrand(Theme): def __init__( self, brand: Brand, - preset: ShinyThemePreset = "shiny", - name: Optional[str] = None, + *, include_paths: Optional[str | Path | list[str | Path]] = None, ): - super().__init__(preset=preset, name=name, include_paths=include_paths) - self.brand = brand + if not isinstance(brand, Brand): + raise ValueError("Invalid `brand`, must be a path or a Brand instance.") - def _html_dependencies(self) -> list[HTMLDependency]: - theme_deps = super()._html_dependencies() + name: str = "brand" + if brand.meta and brand.meta.name: + name = brand.meta.name.full or brand.meta.name.short or "brand" - if not self.brand.typography: - return theme_deps + brand_bootstrap = BrandBootstrap.from_brand(brand) - # We're going to put the fonts dependency _inside_ the theme's tempdir, which - # relies on the theme's dependency having `all_files=True`. - temp_dir = self._get_css_tempdir() - temp_path = Path(temp_dir) / "fonts" - temp_path.mkdir(parents=True, exist_ok=True) - - fonts_dep = self.brand.typography.fonts_html_dependency( - path_dir=temp_path, - name=f"{self._dep_name()}-fonts", - version=self._version, + # Initialize theme ------------------------------------------------------------ + super().__init__( + name=name, + preset=brand_bootstrap.preset, + include_paths=include_paths, ) + self.brand = brand - if fonts_dep is None: - return theme_deps + # brand.color ----------------------------------------------------------------- + sass_vars_colors: dict[str, str] = {} + if brand.color: + # Map values in colors directly to their Sass variable counterparts + sass_vars_colors: dict[str, str] = { + k: v + for k, v in brand.color.model_dump(exclude_none=True).items() + if k not in ("palette", "foreground", "background") + } - return [fonts_dep, *theme_deps] + # Map values in colors to any additional Sass variables + for extra, sass_var_list in color_extras_map.items(): + if extra in sass_vars_colors: + sass_vars_colors_extras = { + var: sass_vars_colors[extra] for var in sass_var_list + } + sass_vars_colors = {**sass_vars_colors, **sass_vars_colors_extras} + + if brand.color.palette: + # Map the brand color palette to Bootstrap's named colors, e.g. $red, $blue. + # Note that we use ._color_defs() to ensure the palette is fully resolved. + brand_color_palette = brand.color._color_defs(resolved=True) + for bs_color_var in bootstrap_colors[brand_bootstrap.version]: + if bs_color_var in brand_color_palette: + sass_vars_colors[bs_color_var] = brand_color_palette[ + bs_color_var + ] + + # brand.typography ------------------------------------------------------------ + sass_vars_typography: dict[str, str] = {} + if brand.typography: + for field in brand.typography.model_fields.keys(): + if field == "fonts": + continue + type_prop = getattr(brand.typography, field) + if type_prop is None: + continue + for k, v in type_prop.model_dump(exclude_none=True).items(): + if k in typography_map[field]: + sass_vars_typography[typography_map[field][k]] = v + else: + # TODO: Need to catch these and map to appropriate Bootstrap vars + print(f"skipping {field}.{k} not mapped") + + sass_vars_brand_colors: dict[str, str] = {} + css_vars_brand_colors: list[str] = [] + + if brand.color and brand.color.palette is not None: + # TODO: sanitize color name into valid sass/css variable names + # Create color variables from palette, `$brand-{name}: {value}` + sass_vars_brand_colors.update( + {f"brand-{k}": v for k, v in brand.color.palette.items()} + ) + # Create CSS variables from palette, `--brand-{name}: {value}` + for k, v in brand.color.palette.items(): + css_vars_brand_colors.append(f"--brand-{k}: {v};") -def theme_from_brand(brand: str | Path | Brand) -> Theme: - """ - Create a custom Shiny theme from a `_brand.yml` - - Creates a custom Shiny theme for your brand using - [brand.yml](https://posit-dev.github.io/brand-yml), which may be either an instance - of :class:`brand_yml.Brand` or a :class:`Path` used by - :meth:`brand_yml.Brand.from_yaml` to locate the `_brand.yml` file. - - Parameters - ---------- - brand - A :class:`brand_yml.Brand` instance, or a path to help locate `_brand.yml`. - For a path, you can pass `__file__` or a directory containing the `_brand.yml` - or a path directly to the `_brand.yml` file. - - Returns - ------- - : - A :class:`shiny.ui.Theme` instance with a custom Shiny theme created from the - brand guidelines (see :class:`brand_yml.Brand`). - """ - if not isinstance(brand, Brand): - brand = Brand.from_yaml(brand) - - if not isinstance(brand, Brand): - raise ValueError("Invalid `brand`, must be a path or a Brand instance.") - - brand_bootstrap = BrandBootstrap.from_brand(brand) - - colors: dict[str, str] = {} - if brand.color: - # Map values in colors directly to their Sass variable counterparts - colors: dict[str, str] = { - k: v - for k, v in brand.color.model_dump(exclude_none=True).items() - if k not in ("palette", "foreground", "background") + sass_vars_brand: dict[str, str] = { + **sass_vars_brand_colors, + **sass_vars_colors, + **sass_vars_typography, } + sass_vars_brand = {k: v for k, v in sass_vars_brand.items()} - # Map values in colors to any additional Sass variables - for extra, sass_var_list in color_extras_map.items(): - if extra in colors: - brand_sass_vars = {var: colors[extra] for var in sass_var_list} - colors = {**colors, **brand_sass_vars} - - if brand.color.palette: - # Map the brand color palette to Bootstrap's named colors, e.g. $red, $blue. - # Note that we use ._color_defs() to ensure the palette is fully resolved. - brand_color_palette = brand.color._color_defs(resolved=True) - for bs_color_var in bootstrap_colors[brand_bootstrap.version]: - if bs_color_var in brand_color_palette: - colors[bs_color_var] = brand_color_palette[bs_color_var] - - typography: dict[str, str] = {} - if brand.typography: - for field in brand.typography.model_fields.keys(): - if field == "fonts": - continue - type_prop = getattr(brand.typography, field) - if type_prop is None: - continue - for k, v in type_prop.model_dump(exclude_none=True).items(): - if k in typography_map[field]: - typography[typography_map[field][k]] = v - else: - # TODO: Need to catch these and map to appropriate Bootstrap vars - print(f"skipping {field}.{k} not mapped") - - brand_colors_sass_vars: dict[str, str] = {} - brand_colors_css_vars: list[str] = [] - - if brand.color and brand.color.palette is not None: - # TODO: sanitize color name into valid sass/css variable names - # Create color variables from palette, `$brand-{name}: {value}` - brand_colors_sass_vars.update( - {f"brand-{k}": v for k, v in brand.color.palette.items()} - ) - - # Create CSS variables from palette, `--brand-{name}: {value}` - for k, v in brand.color.palette.items(): - brand_colors_css_vars.append(f"--brand-{k}: {v};") - - brand_sass_vars: dict[str, str] = {**brand_colors_sass_vars, **colors, **typography} - brand_sass_vars = {k: v for k, v in brand_sass_vars.items()} - - name: str = "brand" - if brand.meta and brand.meta.name: - name = brand.meta.name.full or brand.meta.name.short or "brand" - - return ( - ThemeBrand( - brand, - name=name, - preset=brand_bootstrap.preset, - ) # 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 Sass vars (colors, typography) # 2. Brand Bootstrap Sass vars # 3. Fallback vars needed by additional Brand rules - .add_defaults( + self.add_defaults( # Variables we create to augment Bootstrap's variables **{ "code-font-weight": "normal", @@ -288,12 +247,12 @@ def theme_from_brand(brand: str | Path | Brand) -> Theme: "gray-900": "mix($white, $black, 10%)", } ) - .add_defaults(**brand_bootstrap.defaults) - .add_defaults(**brand_sass_vars) + self.add_defaults(**brand_bootstrap.defaults) + self.add_defaults(**sass_vars_brand) # Brand Rules ---- - .add_rules(":root {", *brand_colors_css_vars, "}") + self.add_rules(":root {", *css_vars_brand_colors, "}") # Additional rules to fill in Bootstrap styles for Brand parameters - .add_rules( + self.add_rules( """ // https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_root.scss#L82 :root { @@ -310,4 +269,28 @@ def theme_from_brand(brand: str | Path | Brand) -> Theme: } """ ) - ) + + def _html_dependencies(self) -> list[HTMLDependency]: + theme_deps = super()._html_dependencies() + + if not self.brand.typography: + return theme_deps + + # We're going to put the fonts dependency _inside_ the theme's tempdir, which + # relies on the theme's dependency having `all_files=True`. We do this because + # Theme handles the tempdir lifecycle and we want the fonts dependency to be + # handled in the same way. + temp_dir = self._get_css_tempdir() + temp_path = Path(temp_dir) / "fonts" + temp_path.mkdir(parents=True, exist_ok=True) + + fonts_dep = self.brand.typography.fonts_html_dependency( + path_dir=temp_path, + name=f"{self._dep_name()}-fonts", + version=self._version, + ) + + if fonts_dep is None: + return theme_deps + + return [fonts_dep, *theme_deps] From 2af094161c486fbcdec7d26534733ef9c3f420d6 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 09:10:05 -0400 Subject: [PATCH 14/82] feat(ThemeBrand): sanitize color name into valid sass/css variable names --- shiny/ui/_theme_brand.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 894919078..496fca61f 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import warnings from pathlib import Path from typing import Any, Optional @@ -208,15 +209,13 @@ def __init__( css_vars_brand_colors: list[str] = [] if brand.color and brand.color.palette is not None: - # TODO: sanitize color name into valid sass/css variable names - # Create color variables from palette, `$brand-{name}: {value}` - sass_vars_brand_colors.update( - {f"brand-{k}": v for k, v in brand.color.palette.items()} - ) + for p_var, p_value in brand.color.palette.items(): + p_var = sanitize_sass_var_name(p_var) - # Create CSS variables from palette, `--brand-{name}: {value}` - for k, v in brand.color.palette.items(): - css_vars_brand_colors.append(f"--brand-{k}: {v};") + # Create color variables from palette, `$brand-{name}: {value}` + sass_vars_brand_colors.update({f"brand-{p_var}": p_value}) + # Create CSS variables from palette, `--brand-{name}: {value}` + css_vars_brand_colors.append(f"--brand-{p_var}: {p_value};") sass_vars_brand: dict[str, str] = { **sass_vars_brand_colors, @@ -294,3 +293,8 @@ def _html_dependencies(self) -> list[HTMLDependency]: return theme_deps return [fonts_dep, *theme_deps] + + +def sanitize_sass_var_name(x: str) -> str: + x = re.sub(r"""['"]""", "", x) + return re.sub(r"[^a-zA-Z0-9_-]+", "-", x) From 03adf5c630d2a1b0ceb93913a572d222506688d2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 10:36:11 -0400 Subject: [PATCH 15/82] refactor: Use `brand.color.to_dict()` method And rewrite the sass color var resolution for clarity --- pyproject.toml | 2 +- shiny/ui/_theme_brand.py | 51 +++++++++++++++++++--------------------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index db3c92a3b..4ae606fc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ [project.optional-dependencies] theme = [ "libsass>=0.23.0", - "brand_yml>=0.1.0rc5" + "brand_yml>=0.1.0rc6" ] test = [ "pytest>=6.2.4", diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 496fca61f..b53e93974 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -12,11 +12,18 @@ from ._theme import Theme from ._theme_presets import ShinyThemePreset, shiny_theme_presets -color_extras_map = { - "foreground": ["body-color", "pre-color", "black"], - "background": ["body-bg", "white"], - "secondary": ["body-secondary-color", "body-secondary"], +color_map = { + "foreground": ["brand--foreground", "body-color", "pre-color"], + "background": ["brand--background", "body-bg"], + "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""" @@ -164,30 +171,20 @@ def __init__( # brand.color ----------------------------------------------------------------- sass_vars_colors: dict[str, str] = {} if brand.color: - # Map values in colors directly to their Sass variable counterparts - sass_vars_colors: dict[str, str] = { - k: v - for k, v in brand.color.model_dump(exclude_none=True).items() - if k not in ("palette", "foreground", "background") - } + # Map values in colors to their Sass variable counterparts + for field, theme_color in brand.color.to_dict(include="theme").items(): + if field not in color_map: + print(f"skipping color.{field} not mapped") + continue + + for sass_var in color_map[field]: + sass_vars_colors[sass_var] = theme_color - # Map values in colors to any additional Sass variables - for extra, sass_var_list in color_extras_map.items(): - if extra in sass_vars_colors: - sass_vars_colors_extras = { - var: sass_vars_colors[extra] for var in sass_var_list - } - sass_vars_colors = {**sass_vars_colors, **sass_vars_colors_extras} - - if brand.color.palette: - # Map the brand color palette to Bootstrap's named colors, e.g. $red, $blue. - # Note that we use ._color_defs() to ensure the palette is fully resolved. - brand_color_palette = brand.color._color_defs(resolved=True) - for bs_color_var in bootstrap_colors[brand_bootstrap.version]: - if bs_color_var in brand_color_palette: - sass_vars_colors[bs_color_var] = brand_color_palette[ - bs_color_var - ] + # Map the brand color palette to Bootstrap's named colors, e.g. $red, $blue. + bs_color_vars = bootstrap_colors[brand_bootstrap.version] + for field, palette_color in brand.color.to_dict(include="palette").items(): + if field in bs_color_vars: + sass_vars_colors[field] = palette_color # brand.typography ------------------------------------------------------------ sass_vars_typography: dict[str, str] = {} From ced644a4bc57db8b8bf25cef59f1ba90e5e4dd2c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 11:41:02 -0400 Subject: [PATCH 16/82] chore: Apply suggestions from code review --- shiny/ui/_theme_brand.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index b53e93974..02baa2720 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -151,12 +151,10 @@ def __init__( *, include_paths: Optional[str | Path | list[str | Path]] = None, ): - if not isinstance(brand, Brand): - raise ValueError("Invalid `brand`, must be a path or a Brand instance.") name: str = "brand" if brand.meta and brand.meta.name: - name = brand.meta.name.full or brand.meta.name.short or "brand" + name = brand.meta.name.full or brand.meta.name.short or name brand_bootstrap = BrandBootstrap.from_brand(brand) From 409a53bbae480c3b80283586ab0e47a76a4969cc Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 11:43:57 -0400 Subject: [PATCH 17/82] refactor: More strongly type sass var maps --- shiny/ui/_theme_brand.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 02baa2720..25d164ef5 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -12,7 +12,7 @@ from ._theme import Theme from ._theme_presets import ShinyThemePreset, shiny_theme_presets -color_map = { +color_map: dict[str, list[str]] = { "foreground": ["brand--foreground", "body-color", "pre-color"], "background": ["brand--background", "body-bg"], "primary": ["primary"], @@ -54,7 +54,7 @@ """ # TODO: test that these Sass variables exist in Bootstrap -typography_map = { +typography_map: dict[str, dict[str, str]] = { "base": { "family": "font-family-base", "size": "font-size-base", From dcf4e327e494893fc02872b216a5cf2dc82e5adb Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 11:47:31 -0400 Subject: [PATCH 18/82] refactor: Don't version the bootstrap color list --- shiny/ui/_theme_brand.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 25d164ef5..3ad510419 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -27,23 +27,21 @@ } """Maps brand.color fields to Bootstrap Sass variables""" -bootstrap_colors = { - # https://github.com/twbs/bootstrap/blob/6e1f75/scss/_variables.scss#L38-L49 - "5": [ - "white", - "black", - "blue", - "indigo", - "purple", - "pink", - "red", - "orange", - "yellow", - "green", - "teal", - "cyan", - ] -} +# https://github.com/twbs/bootstrap/blob/6e1f75/scss/_variables.scss#L38-L49 +bootstrap_colors: list[str] = [ + "white", + "black", + "blue", + "indigo", + "purple", + "pink", + "red", + "orange", + "yellow", + "green", + "teal", + "cyan", +] """ Colors known to Bootstrap @@ -172,6 +170,7 @@ def __init__( # Map values in colors to their Sass variable counterparts for field, theme_color in brand.color.to_dict(include="theme").items(): if field not in color_map: + # TODO: Catch and ensure mapping exists print(f"skipping color.{field} not mapped") continue @@ -179,9 +178,8 @@ def __init__( sass_vars_colors[sass_var] = theme_color # Map the brand color palette to Bootstrap's named colors, e.g. $red, $blue. - bs_color_vars = bootstrap_colors[brand_bootstrap.version] for field, palette_color in brand.color.to_dict(include="palette").items(): - if field in bs_color_vars: + if field in bootstrap_colors: sass_vars_colors[field] = palette_color # brand.typography ------------------------------------------------------------ From 948ddce9a12b212c15c6006cc074af8a6b421b67 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 12:43:19 -0400 Subject: [PATCH 19/82] refactor: Consolidate all brand.color sass var logic --- shiny/ui/_theme_brand.py | 41 +++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 3ad510419..b2e5f1434 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -166,21 +166,36 @@ def __init__( # brand.color ----------------------------------------------------------------- sass_vars_colors: dict[str, str] = {} + sass_vars_brand_colors: dict[str, str] = {} + css_vars_brand_colors: list[str] = [] + if brand.color: # Map values in colors to their Sass variable counterparts - for field, theme_color in brand.color.to_dict(include="theme").items(): - if field not in color_map: + for palette_name, theme_color in brand.color.to_dict( + include="theme" + ).items(): + if palette_name not in color_map: # TODO: Catch and ensure mapping exists - print(f"skipping color.{field} not mapped") + print(f"skipping color.{palette_name} not mapped") continue - for sass_var in color_map[field]: + for sass_var in color_map[palette_name]: sass_vars_colors[sass_var] = theme_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 field, palette_color in brand.color.to_dict(include="palette").items(): - if field in bootstrap_colors: - sass_vars_colors[field] = palette_color + for palette_name, palette_color in brand_color_palette.items(): + if palette_name in bootstrap_colors: + sass_vars_colors[palette_name] = palette_color + + # Create Sass and CSS variables for the brand color palette + color_var = sanitize_sass_var_name(palette_name) + + # => Sass var: `$brand-{name}: {value}` + sass_vars_brand_colors.update({f"brand-{color_var}": palette_color}) + # => CSS var: `--brand-{name}: {value}` + css_vars_brand_colors.append(f"--brand-{color_var}: {palette_color};") # brand.typography ------------------------------------------------------------ sass_vars_typography: dict[str, str] = {} @@ -198,18 +213,6 @@ def __init__( # TODO: Need to catch these and map to appropriate Bootstrap vars print(f"skipping {field}.{k} not mapped") - sass_vars_brand_colors: dict[str, str] = {} - css_vars_brand_colors: list[str] = [] - - if brand.color and brand.color.palette is not None: - for p_var, p_value in brand.color.palette.items(): - p_var = sanitize_sass_var_name(p_var) - - # Create color variables from palette, `$brand-{name}: {value}` - sass_vars_brand_colors.update({f"brand-{p_var}": p_value}) - # Create CSS variables from palette, `--brand-{name}: {value}` - css_vars_brand_colors.append(f"--brand-{p_var}: {p_value};") - sass_vars_brand: dict[str, str] = { **sass_vars_brand_colors, **sass_vars_colors, From 142409e5ea18975ee20d6d1a86e2eeace8883a38 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 13:00:44 -0400 Subject: [PATCH 20/82] refactor: Raise ThemeBrandUnmappedFieldError following envvar SHINY_BRAND_YML_RAISE_UNMAPPED="true" For testing --- shiny/ui/_theme_brand.py | 73 ++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index b2e5f1434..09d7f98e3 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import re import warnings from pathlib import Path @@ -12,6 +13,17 @@ from ._theme import Theme from ._theme_presets import ShinyThemePreset, shiny_theme_presets + +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 + + color_map: dict[str, list[str]] = { "foreground": ["brand--foreground", "body-color", "pre-color"], "background": ["brand--background", "body-bg"], @@ -169,50 +181,61 @@ def __init__( sass_vars_brand_colors: dict[str, str] = {} css_vars_brand_colors: list[str] = [] + raise_for_unmapped_vars = ( + os.environ.get("SHINY_BRAND_YML_RAISE_UNMAPPED") == "true" + ) + if brand.color: # Map values in colors to their Sass variable counterparts - for palette_name, theme_color in brand.color.to_dict( - include="theme" - ).items(): - if palette_name not in color_map: - # TODO: Catch and ensure mapping exists - print(f"skipping color.{palette_name} not mapped") + for thm_name, thm_color in brand.color.to_dict(include="theme").items(): + if thm_name not in color_map: + if raise_for_unmapped_vars: + raise ThemeBrandUnmappedFieldError(f"color.{thm_name}") continue - for sass_var in color_map[palette_name]: - sass_vars_colors[sass_var] = theme_color + for sass_var in color_map[thm_name]: + sass_vars_colors[sass_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 palette_name, palette_color in brand_color_palette.items(): - if palette_name in bootstrap_colors: - sass_vars_colors[palette_name] = palette_color + for pal_name, pal_color in brand_color_palette.items(): + if pal_name in bootstrap_colors: + sass_vars_colors[pal_name] = pal_color # Create Sass and CSS variables for the brand color palette - color_var = sanitize_sass_var_name(palette_name) + color_var = sanitize_sass_var_name(pal_name) # => Sass var: `$brand-{name}: {value}` - sass_vars_brand_colors.update({f"brand-{color_var}": palette_color}) + sass_vars_brand_colors.update({f"brand-{color_var}": pal_color}) # => CSS var: `--brand-{name}: {value}` - css_vars_brand_colors.append(f"--brand-{color_var}: {palette_color};") + css_vars_brand_colors.append(f"--brand-{color_var}: {pal_color};") # brand.typography ------------------------------------------------------------ sass_vars_typography: dict[str, str] = {} if brand.typography: - for field in brand.typography.model_fields.keys(): - if field == "fonts": - continue - type_prop = getattr(brand.typography, field) - if type_prop is None: + brand_typography = brand.typography.model_dump( + exclude={"fonts"}, + exclude_none=True, + ) + + for typ_field, typ_value in brand_typography.items(): + if typ_field not in typography_map: + if raise_for_unmapped_vars: + raise ThemeBrandUnmappedFieldError(f"typography.{typ_field}") continue - for k, v in type_prop.model_dump(exclude_none=True).items(): - if k in typography_map[field]: - sass_vars_typography[typography_map[field][k]] = v - else: - # TODO: Need to catch these and map to appropriate Bootstrap vars - print(f"skipping {field}.{k} not mapped") + for typ_field_key, typ_field_value in typ_value.items(): + if typ_field_key in typography_map[typ_field]: + typo_sass_var = typography_map[typ_field][typ_field_key] + + sass_vars_typography[typo_sass_var] = typ_field_value + elif raise_for_unmapped_vars: + raise ThemeBrandUnmappedFieldError( + f"typography.{typ_field}.{typ_field_key}" + ) + + # Theme ----------------------------------------------------------------------- sass_vars_brand: dict[str, str] = { **sass_vars_brand_colors, **sass_vars_colors, From 048622e7428932c2d792f8f811c4489bf40586f0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 13:02:02 -0400 Subject: [PATCH 21/82] chore: Update brand.ya?ml reload includes --- shiny/_main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shiny/_main.py b/shiny/_main.py index dd9987fc6..0c13784c6 100644 --- a/shiny/_main.py +++ b/shiny/_main.py @@ -41,7 +41,8 @@ def main() -> None: "*.htm", "*.html", "*.png", - "_brand*.yml", + "*brand*.yml", + "*brand*.yaml", ) RELOAD_EXCLUDES_DEFAULT = (".*", "*.py[cod]", "__pycache__", "env", "venv") From fc66b215b1b80d4d9316495c99313954ada21274 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 15:08:46 -0400 Subject: [PATCH 22/82] feat: Convert `typography.base.size` to `rem` for Bootstrap Otherwise Bootstrap does calculations with incompatible units --- shiny/ui/_theme_brand.py | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 09d7f98e3..6ddad0116 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -12,6 +12,7 @@ from .._versions import bootstrap from ._theme import Theme from ._theme_presets import ShinyThemePreset, shiny_theme_presets +from .css import CssUnit, as_css_unit class ThemeBrandUnmappedFieldError(ValueError): @@ -229,6 +230,11 @@ def __init__( if typ_field_key in typography_map[typ_field]: typo_sass_var = typography_map[typ_field][typ_field_key] + if typ_field == "base" and typ_field_key == "size": + typ_field_value = str( + maybe_convert_font_size_to_rem(typ_field_value) + ) + sass_vars_typography[typo_sass_var] = typ_field_value elif raise_for_unmapped_vars: raise ThemeBrandUnmappedFieldError( @@ -317,3 +323,65 @@ def _html_dependencies(self) -> list[HTMLDependency]: def sanitize_sass_var_name(x: str) -> str: x = re.sub(r"""['"]""", "", x) return re.sub(r"[^a-zA-Z0-9_-]+", "-", x) + + +def maybe_convert_font_size_to_rem(x: str) -> CssUnit: + """ + Convert a font size to rem + + Bootstrap expects base font size to be in `rem`. This function converts `em`, `%`, + `px`, `pt` to `rem`: + + 1. `em` is directly replace with `rem`. + 2. `1%` is `0.01rem`, e.g. `90%` becomes `0.9rem`. + 3. `16px` is `1rem`, e.g. `18px` becomes `1.125rem`. + 4. `12pt` is `1rem`. + 5. `0.1666in` is `1rem`. + 6. `4.234cm` is `1rem`. + 7. `42.3mm` is `1rem`. + """ + x_og = f"{x}" + x = as_css_unit(x) + + value, unit = split_css_value_and_unit(x) + + if unit == "rem": + return x + + if unit == "em": + return as_css_unit(f"{value}rem") + + scale = { + "%": 100, + "px": 16, + "pt": 12, + "in": 96 / 16, # 96 px/inch + "cm": 96 / 16 * 2.54, # inch -> cm + "mm": 16 / 96 * 25.4, # cm -> mm + } + + if unit in scale: + return as_css_unit(f"{float(value) / scale[unit]}rem") + + raise ValueError( + f"Shiny does not support brand.yml font sizes in {unit} units ({x_og!r})" + ) + + +def split_css_value_and_unit(x: str) -> tuple[str, str]: + digit_chars = [".", *[str(s) for s in range(10)]] + + value = "" + unit = "" + in_unit = False + for chr in x: + if chr in digit_chars: + if not in_unit: + value += chr + else: + in_unit = True + + if in_unit: + unit += chr + + return value.strip(), unit.strip() From 20b1210b3490da043624ae79f0911d9d52e276de Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 16:19:27 -0400 Subject: [PATCH 23/82] feat(brand): Add example app and brand --- examples/brand/_brand.yml | 108 +++++++++++++++ examples/brand/app.py | 270 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 examples/brand/_brand.yml create mode 100644 examples/brand/app.py diff --git a/examples/brand/_brand.yml b/examples/brand/_brand.yml new file mode 100644 index 000000000..cadbc8967 --- /dev/null +++ b/examples/brand/_brand.yml @@ -0,0 +1,108 @@ +meta: + name: + full: "Retro Arcade Brand" + short: "RetroArc" + link: + home: https://retroarc.example.com + mastodon: https://mastodon.social/@retroarc + github: https://github.com/retroarc + linkedin: https://linkedin.com/company/retroarc + twitter: https://twitter.com/retroarc + facebook: https://facebook.com/retroarc + +# logo: +# images: +# icon-light: logos/retroarc-icon-light.png +# icon-dark: logos/retroarc-icon-dark.png +# wide-light: logos/retroarc-wide-light.png +# wide-dark: logos/retroarc-wide-dark.png +# tall-light: logos/retroarc-tall-light.png +# tall-dark: logos/retroarc-tall-dark.png +# small: +# light: logos/retroarc-icon-light.png +# dark: logos/retroarc-icon-dark.png +# medium: +# light: logos/retroarc-wide-light.png +# dark: logos/retroarc-wide-dark.png +# large: +# light: logos/retroarc-tall-light.png +# dark: logos/retroarc-tall-dark.png + +color: + palette: + pink: "#E83E8C" + blue: "#007BFF" + cyan: "#17A2B8" + teal: "#20C997" + green: "#28A745" + yellow: "#FFD700" + orange: "#FF7F50" + red: "#FF3333" + purple: "#6F42C1" + indigo: "#6610F2" + black: "#1A1A1A" + white: "#F8F8F8" + foreground: black + background: white + primary: purple + success: green + info: cyan + warning: yellow + danger: orange + light: white + dark: black + +typography: + fonts: + - family: "Quantico" + source: google + weight: [700] + style: [normal] + display: swap + - family: "Monda" + source: google + weight: 400..700 + style: [normal, italic] + display: swap + - family: "Courier Prime" + source: google + weight: [400, 700] + style: [normal, italic] + display: swap + base: + family: "Monda" + size: "1em" + weight: 400 + line-height: 1.5 + headings: + family: "Quantico" + weight: 400 + line-height: 1.2 + style: normal + monospace: + family: "Courier Prime" + size: "0.9em" + weight: 400 + monospace-inline: + family: "Courier Prime" + size: "0.9em" + weight: 400 + color: yellow + background-color: "#1a1a1add" + monospace-block: + family: "Courier Prime" + size: "0.9em" + weight: 400 + color: green + background-color: black + line-height: 1.4 + link: + weight: 400 + background-color: purple + color: white + decoration: "underline" + +defaults: + shiny: + theme: + navbar-bg: $brand-purple diff --git a/examples/brand/app.py b/examples/brand/app.py new file mode 100644 index 000000000..5fb93bffe --- /dev/null +++ b/examples/brand/app.py @@ -0,0 +1,270 @@ +import os + +import matplotlib.pyplot as plt +import numpy as np + +from shiny import App, render, ui + +# TODO: Move this into the test that runs this app +os.environ["SHINY_BRAND_YML_RAISE_UNMAPPED"] = "true" + +theme = ui.Theme.from_brand(__file__) + +app_ui = ui.page_navbar( + ui.nav_panel( + "Input Output Demo", + ui.page_sidebar( + ui.sidebar( + ui.input_slider("slider1", "Numeric Slider Input", 0, 11, 11), + ui.input_numeric("numeric1", "Numeric Input Widget", 30), + ui.input_date("date1", "Date Input Component", value="2024-01-01"), + ui.input_switch("switch1", "Binary Switch Input", True), + ui.input_radio_buttons( + "radio1", + "Radio Button Group", + choices=["Option A", "Option B", "Option C", "Option D"], + ), + ui.input_action_button("action1", "Action Button"), + ), + ui.layout_column_wrap( + ui.value_box( + "Metric 1", + "100", + theme="primary", + ), + ui.value_box( + "Metric 2", + "200", + theme="secondary", + ), + ui.value_box( + "Metric 3", + "300", + theme="info", + ), + ), + ui.card( + ui.card_header("Plot Output"), + ui.output_plot("plot1"), + ), + ui.card( + ui.card_header("Text Output"), + ui.output_text_verbatim("out_text1"), + ), + ), + ), + ui.nav_panel( + "Widget Gallery", + ui.layout_column_wrap( + ui.card( + ui.card_header("Button Variants"), + ui.input_action_button("btn1", "Default"), + ui.input_action_button("btn2", "Primary", class_="btn-primary"), + ui.input_action_button("btn3", "Secondary", class_="btn-secondary"), + ui.input_action_button("btn4", "Info", class_="btn-info"), + ui.input_action_button("btn5", "Success", class_="btn-success"), + ui.input_action_button("btn6", "Warning", class_="btn-warning"), + ui.input_action_button("btn7", "Danger", class_="btn-danger"), + ), + ui.card( + ui.card_header("Radio Button Examples"), + ui.input_radio_buttons( + "radio2", + "Standard Radio Group", + ["Selection 1", "Selection 2", "Selection 3"], + ), + ui.input_radio_buttons( + "radio3", + "Inline Radio Group", + ["Option 1", "Option 2", "Option 3"], + inline=True, + ), + ), + ui.card( + ui.card_header("Checkbox Examples"), + ui.input_checkbox_group( + "check1", + "Standard Checkbox Group", + ["Item 1", "Item 2", "Item 3"], + ), + ui.input_checkbox_group( + "check2", + "Inline Checkbox Group", + ["Choice A", "Choice B", "Choice C"], + inline=True, + ), + ), + ui.card( + ui.card_header("Select Input Widgets"), + ui.input_selectize( + "select1", + "Selectize Input", + ["Selection A", "Selection B", "Selection C"], + ), + ui.input_select( + "select2", + "Multiple Select Input", + ["Item X", "Item Y", "Item Z"], + multiple=True, + ), + ), + ui.card( + ui.card_header("Text Input Widgets"), + ui.input_text("text1", "Text Input"), + ui.input_text_area( + "textarea1", + "Text Area Input", + "Default text content for the text area widget", + ), + ui.input_password("password1", "Password Input"), + ), + width=400, + heights_equal=False, + ), + ), + ui.nav_panel( + "Documentation", + ui.div( + ui.markdown( + """ + _Just in case it isn't obvious, this text (and most of this app) was written by an LLM._ + + # Component Documentation + + The Shiny for Python framework, available at [shiny.posit.co/py](https://shiny.posit.co/py/), + provides a comprehensive set of UI components for building interactive web applications. These + components are designed with **consistency and usability** in mind, making it easier for + developers to create professional-grade applications. + + Our framework implements the `ui.page_navbar()` component as the primary navigation structure, + allowing for intuitive organization of content across multiple views. Each view can contain + various input and output elements, managed through the `ui.card()` container system. + + ## Component Architecture + + *The architecture of our application framework* emphasizes modularity and reusability. Key + components like `ui.value_box()` and `ui.layout_column_wrap()` work together to create + structured, responsive layouts that adapt to different screen sizes. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentImplementationUse CaseStatus
Value Boxui.value_box()Metric DisplayProduction Ready
Cardui.card()Content ContainerProduction Ready
Layoutui.layout_column_wrap()Component OrganizationProduction Ready
Navigationui.page_navbar()Page StructureProduction Ready
+ + ## Implementation Best Practices + + When implementing components, maintain consistent patterns in your code. Use the + `@render` decorators for output functions and follow the reactive programming model + with `@reactive.effect` for side effects. + + Error handling should be implemented at both the UI and server levels. For input + validation, use the `req()` function to ensure all required values are present + before processing. + + ## Corporate Brand Guidelines + + Effective corporate brand guidelines should accomplish several key objectives: + + 1. **Visual Consistency**: Establish a clear color palette using our theming system. + Primary colors should be defined using `class_="btn-primary"` and similar Bootstrap + classes. + + 2. *Typography Standards*: Maintain consistent font usage across all text elements. + Headers should use the built-in styling provided by the `ui.card_header()` component. + + 3. `Component Styling`: Apply consistent styling to UI elements such as buttons, + cards, and value boxes. Use the theme parameter in components like + `ui.value_box(theme="primary")`. + + 4. **Layout Principles**: Follow a grid-based layout system using + `ui.layout_column_wrap()` with appropriate width parameters to ensure consistent + spacing and alignment. + + 5. *Responsive Design*: Implement layouts that adapt gracefully to different screen + sizes using the `fillable` parameter in page components. + + Remember that brand guidelines should serve as a framework for consistency while + remaining flexible enough to accommodate future updates and modifications to the + application interface. + """ + ), + class_="container-sm", + ), + ), + ui.nav_spacer(), + ui.nav_control(ui.input_dark_mode(id="color_mode")), + title="brand.yml Demo", + fillable=["Input Output Demo", "Widget Gallery"], + theme=theme, +) + + +def server(input, output, session): + @render.plot + def plot1(): + colors = { + "foreground": "#000000", + "background": "#FFFFFF", + "primary": "#4463ff", + } + + if theme.brand.color: + colors.update(theme.brand.color.to_dict("theme")) + + if input.color_mode() == "dark": + bg = colors["foreground"] + fg = colors["background"] + colors.update({"foreground": fg, "background": bg}) + + x = np.linspace(0, input.numeric1(), 100) + y = np.sin(x) * input.slider1() + fig, ax = plt.subplots(facecolor=colors["background"]) + ax.plot(x, y, color=colors["primary"]) + ax.set_title("Sine Wave Output", color=colors["foreground"]) + ax.set_facecolor(colors["background"]) + ax.tick_params(colors=colors["foreground"]) + for spine in ax.spines.values(): + spine.set_edgecolor(colors["foreground"]) + spine.set_alpha(0.25) + return fig + + @render.text + def out_text1(): + return "\n".join( + ["def example_function():", ' return "Function output text"'] + ) + + +app = App(app_ui, server) From b905415f07d3ef7c5a844728450ed3930b2d6067 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 16:19:44 -0400 Subject: [PATCH 24/82] feat(brand): finish mapping the variables --- shiny/ui/_theme_brand.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 6ddad0116..97b5afced 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -82,19 +82,22 @@ def __str__(self): "monospace": { "family": "font-family-monospace", "size": "code-font-size", + "weight": "code-font-weight", }, "monospace_inline": { "family": "font-family-monospace-inline", "color": "code-color", "background_color": "code-bg", "size": "code-inline-font-size", - "weight": "code-font-weight", + "weight": "code-inline-font-weight", }, "monospace_block": { "family": "font-family-monospace-block", "line_height": "pre-line-height", "color": "pre-color", "background_color": "pre-bg", + "weight": "code-block-font-weight", + "size": "code-block-font-size", }, "link": { "background_color": "link-bg", @@ -257,7 +260,11 @@ def __init__( self.add_defaults( # Variables we create to augment Bootstrap's variables **{ - "code-font-weight": "normal", + "code-font-weight": None, + "code-inline-font-weight": None, + "code-inline-font-size": None, + "code-block-font-weight": None, + "code-block-font-size": None, "link-bg": None, "link-weight": None, "gray-100": "mix($white, $black, 90%)", @@ -291,6 +298,14 @@ def __init__( code { font-weight: $code-font-weight; } + code { + font-weight: $code-inline-font-weight; + font-size: $code-inline-font-size; + } + pre { + font-weight: $code-block-font-weight; + font-size: $code-block-font-size; + } """ ) From 95ffd5a6b0a0350de69f82054b240b6bd0b18d00 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 16:22:12 -0400 Subject: [PATCH 25/82] chore: Add some notes in example brand --- examples/brand/_brand.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/brand/_brand.yml b/examples/brand/_brand.yml index cadbc8967..e44a5b719 100644 --- a/examples/brand/_brand.yml +++ b/examples/brand/_brand.yml @@ -59,13 +59,14 @@ typography: weight: [700] style: [normal] display: swap + # TODO: Bring in Monda as a local font file - family: "Monda" source: google weight: 400..700 style: [normal, italic] display: swap - family: "Courier Prime" - source: google + source: bunny weight: [400, 700] style: [normal, italic] display: swap @@ -105,4 +106,5 @@ typography: defaults: shiny: theme: - navbar-bg: $brand-purple + # TODO: Find an appropriate theme variable to set + # navbar-bg: $brand-purple From db424018e6582ccf4bbaff94ea49691fedda8e24 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 16:30:04 -0400 Subject: [PATCH 26/82] chore: add a few more notes --- shiny/ui/_theme_brand.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 97b5afced..6e478eddd 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -151,9 +151,17 @@ def from_brand(cls, brand: Brand): if brand.defaults: if brand.defaults and "bootstrap" in brand.defaults: - defaults.update(brand.defaults["bootstrap"]) + if isinstance(brand.defaults["bootstrap"], dict): + brand_defaults_bs: dict[str, str] = brand.defaults["bootstrap"] + defaults.update(brand_defaults_bs) if "shiny" in brand.defaults and "theme" in brand.defaults["shiny"]: - defaults.update(brand.defaults["shiny"]["theme"]) + if isinstance(brand.defaults["shiny"]["theme"], dict): + # TODO: Use brand.defaults.shiny.theme.defaults instead + # TODO: Validate that it's really a dict[str, scalar] + brand_shiny_theme: dict[str, str] = brand.defaults["shiny"]["theme"] + defaults.update(brand_shiny_theme) + + # TODO: Get functions, mixins, rules as well return cls(**defaults) From f785d7531dd532fa90ff1903dde92488b7e63127 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 21:41:59 -0400 Subject: [PATCH 27/82] refactor: Simplify splitting css value and unit Co-authored-by: Carson Sievert --- shiny/ui/_theme_brand.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 6e478eddd..e358101fd 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -392,19 +392,7 @@ def maybe_convert_font_size_to_rem(x: str) -> CssUnit: def split_css_value_and_unit(x: str) -> tuple[str, str]: - digit_chars = [".", *[str(s) for s in range(10)]] - - value = "" - unit = "" - in_unit = False - for chr in x: - if chr in digit_chars: - if not in_unit: - value += chr - else: - in_unit = True - - if in_unit: - unit += chr - - return value.strip(), unit.strip() + match = re.match(r'^(-?\d*\.?\d+)([a-zA-Z%]*)$', x) + if not match: + raise ValueError(f"Invalid CSS value format: {x}") + return match.groups() From aaed92af383597b5fb6b6e2b6cb83b86d0698317 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 21:29:09 -0400 Subject: [PATCH 28/82] chore: link back to bootstrap source for code rules --- shiny/ui/_theme_brand.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index e358101fd..07d54731c 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -310,6 +310,7 @@ def __init__( 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-weight: $code-block-font-weight; font-size: $code-block-font-size; From a012b8a985084bd9e8b8855363fa195428afb30c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 21:40:35 -0400 Subject: [PATCH 29/82] fix(brand): Map typography.link.color to $link-color-dark too --- shiny/ui/_theme_brand.py | 62 ++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 07d54731c..ac072da4b 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -65,45 +65,45 @@ def __str__(self): """ # TODO: test that these Sass variables exist in Bootstrap -typography_map: dict[str, dict[str, str]] = { +typography_map: dict[str, dict[str, list[str]]] = { "base": { - "family": "font-family-base", - "size": "font-size-base", - "line_height": "line-height-base", - "weight": "font-weight-base", + "family": ["font-family-base"], + "size": ["font-size-base"], + "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", + "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", + "family": ["font-family-monospace"], + "size": ["code-font-size"], + "weight": ["code-font-weight"], }, "monospace_inline": { - "family": "font-family-monospace-inline", - "color": "code-color", - "background_color": "code-bg", - "size": "code-inline-font-size", - "weight": "code-inline-font-weight", + "family": ["font-family-monospace-inline"], + "color": ["code-color"], + "background_color": ["code-bg"], + "size": ["code-inline-font-size"], + "weight": ["code-inline-font-weight"], }, "monospace_block": { - "family": "font-family-monospace-block", - "line_height": "pre-line-height", - "color": "pre-color", - "background_color": "pre-bg", - "weight": "code-block-font-weight", - "size": "code-block-font-size", + "family": ["font-family-monospace-block"], + "line_height": ["pre-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", - "weight": "link-weight", - "decoration": "link-decoration", + "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""" @@ -239,14 +239,14 @@ def __init__( for typ_field_key, typ_field_value in typ_value.items(): if typ_field_key in typography_map[typ_field]: - typo_sass_var = typography_map[typ_field][typ_field_key] - if typ_field == "base" and typ_field_key == "size": typ_field_value = str( maybe_convert_font_size_to_rem(typ_field_value) ) - sass_vars_typography[typo_sass_var] = typ_field_value + typo_sass_vars = typography_map[typ_field][typ_field_key] + for typo_sass_var in typo_sass_vars: + sass_vars_typography[typo_sass_var] = typ_field_value elif raise_for_unmapped_vars: raise ThemeBrandUnmappedFieldError( f"typography.{typ_field}.{typ_field_key}" From a9fb96844e0a836a54e92a8f7f5a4da88892cc65 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 23 Oct 2024 22:14:48 -0400 Subject: [PATCH 30/82] feat(example): Add colors page --- examples/brand/_colors.scss | 114 ++++++++++++++++++++++++++++++++++++ examples/brand/app.py | 40 ++++++++++++- 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 examples/brand/_colors.scss diff --git a/examples/brand/_colors.scss b/examples/brand/_colors.scss new file mode 100644 index 000000000..4c1c97bd0 --- /dev/null +++ b/examples/brand/_colors.scss @@ -0,0 +1,114 @@ +// https://github.com/twbs/bootstrap/blob/v5.3.3/site/assets/scss/_colors.scss + +.bd-blue-100 { color: color-contrast($blue-100); background-color: $blue-100; } +.bd-blue-200 { color: color-contrast($blue-200); background-color: $blue-200; } +.bd-blue-300 { color: color-contrast($blue-300); background-color: $blue-300; } +.bd-blue-400 { color: color-contrast($blue-400); background-color: $blue-400; } +.bd-blue-500 { color: color-contrast($blue-500); background-color: $blue-500; } +.bd-blue-600 { color: color-contrast($blue-600); background-color: $blue-600; } +.bd-blue-700 { color: color-contrast($blue-700); background-color: $blue-700; } +.bd-blue-800 { color: color-contrast($blue-800); background-color: $blue-800; } +.bd-blue-900 { color: color-contrast($blue-900); background-color: $blue-900; } + +.bd-indigo-100 { color: color-contrast($indigo-100); background-color: $indigo-100; } +.bd-indigo-200 { color: color-contrast($indigo-200); background-color: $indigo-200; } +.bd-indigo-300 { color: color-contrast($indigo-300); background-color: $indigo-300; } +.bd-indigo-400 { color: color-contrast($indigo-400); background-color: $indigo-400; } +.bd-indigo-500 { color: color-contrast($indigo-500); background-color: $indigo-500; } +.bd-indigo-600 { color: color-contrast($indigo-600); background-color: $indigo-600; } +.bd-indigo-700 { color: color-contrast($indigo-700); background-color: $indigo-700; } +.bd-indigo-800 { color: color-contrast($indigo-800); background-color: $indigo-800; } +.bd-indigo-900 { color: color-contrast($indigo-900); background-color: $indigo-900; } + +.bd-purple-100 { color: color-contrast($purple-100); background-color: $purple-100; } +.bd-purple-200 { color: color-contrast($purple-200); background-color: $purple-200; } +.bd-purple-300 { color: color-contrast($purple-300); background-color: $purple-300; } +.bd-purple-400 { color: color-contrast($purple-400); background-color: $purple-400; } +.bd-purple-500 { color: color-contrast($purple-500); background-color: $purple-500; } +.bd-purple-600 { color: color-contrast($purple-600); background-color: $purple-600; } +.bd-purple-700 { color: color-contrast($purple-700); background-color: $purple-700; } +.bd-purple-800 { color: color-contrast($purple-800); background-color: $purple-800; } +.bd-purple-900 { color: color-contrast($purple-900); background-color: $purple-900; } + +.bd-pink-100 { color: color-contrast($pink-100); background-color: $pink-100; } +.bd-pink-200 { color: color-contrast($pink-200); background-color: $pink-200; } +.bd-pink-300 { color: color-contrast($pink-300); background-color: $pink-300; } +.bd-pink-400 { color: color-contrast($pink-400); background-color: $pink-400; } +.bd-pink-500 { color: color-contrast($pink-500); background-color: $pink-500; } +.bd-pink-600 { color: color-contrast($pink-600); background-color: $pink-600; } +.bd-pink-700 { color: color-contrast($pink-700); background-color: $pink-700; } +.bd-pink-800 { color: color-contrast($pink-800); background-color: $pink-800; } +.bd-pink-900 { color: color-contrast($pink-900); background-color: $pink-900; } + +.bd-red-100 { color: color-contrast($red-100); background-color: $red-100; } +.bd-red-200 { color: color-contrast($red-200); background-color: $red-200; } +.bd-red-300 { color: color-contrast($red-300); background-color: $red-300; } +.bd-red-400 { color: color-contrast($red-400); background-color: $red-400; } +.bd-red-500 { color: color-contrast($red-500); background-color: $red-500; } +.bd-red-600 { color: color-contrast($red-600); background-color: $red-600; } +.bd-red-700 { color: color-contrast($red-700); background-color: $red-700; } +.bd-red-800 { color: color-contrast($red-800); background-color: $red-800; } +.bd-red-900 { color: color-contrast($red-900); background-color: $red-900; } + +.bd-orange-100 { color: color-contrast($orange-100); background-color: $orange-100; } +.bd-orange-200 { color: color-contrast($orange-200); background-color: $orange-200; } +.bd-orange-300 { color: color-contrast($orange-300); background-color: $orange-300; } +.bd-orange-400 { color: color-contrast($orange-400); background-color: $orange-400; } +.bd-orange-500 { color: color-contrast($orange-500); background-color: $orange-500; } +.bd-orange-600 { color: color-contrast($orange-600); background-color: $orange-600; } +.bd-orange-700 { color: color-contrast($orange-700); background-color: $orange-700; } +.bd-orange-800 { color: color-contrast($orange-800); background-color: $orange-800; } +.bd-orange-900 { color: color-contrast($orange-900); background-color: $orange-900; } + +.bd-yellow-100 { color: color-contrast($yellow-100); background-color: $yellow-100; } +.bd-yellow-200 { color: color-contrast($yellow-200); background-color: $yellow-200; } +.bd-yellow-300 { color: color-contrast($yellow-300); background-color: $yellow-300; } +.bd-yellow-400 { color: color-contrast($yellow-400); background-color: $yellow-400; } +.bd-yellow-500 { color: color-contrast($yellow-500); background-color: $yellow-500; } +.bd-yellow-600 { color: color-contrast($yellow-600); background-color: $yellow-600; } +.bd-yellow-700 { color: color-contrast($yellow-700); background-color: $yellow-700; } +.bd-yellow-800 { color: color-contrast($yellow-800); background-color: $yellow-800; } +.bd-yellow-900 { color: color-contrast($yellow-900); background-color: $yellow-900; } + +.bd-green-100 { color: color-contrast($green-100); background-color: $green-100; } +.bd-green-200 { color: color-contrast($green-200); background-color: $green-200; } +.bd-green-300 { color: color-contrast($green-300); background-color: $green-300; } +.bd-green-400 { color: color-contrast($green-400); background-color: $green-400; } +.bd-green-500 { color: color-contrast($green-500); background-color: $green-500; } +.bd-green-600 { color: color-contrast($green-600); background-color: $green-600; } +.bd-green-700 { color: color-contrast($green-700); background-color: $green-700; } +.bd-green-800 { color: color-contrast($green-800); background-color: $green-800; } +.bd-green-900 { color: color-contrast($green-900); background-color: $green-900; } + +.bd-teal-100 { color: color-contrast($teal-100); background-color: $teal-100; } +.bd-teal-200 { color: color-contrast($teal-200); background-color: $teal-200; } +.bd-teal-300 { color: color-contrast($teal-300); background-color: $teal-300; } +.bd-teal-400 { color: color-contrast($teal-400); background-color: $teal-400; } +.bd-teal-500 { color: color-contrast($teal-500); background-color: $teal-500; } +.bd-teal-600 { color: color-contrast($teal-600); background-color: $teal-600; } +.bd-teal-700 { color: color-contrast($teal-700); background-color: $teal-700; } +.bd-teal-800 { color: color-contrast($teal-800); background-color: $teal-800; } +.bd-teal-900 { color: color-contrast($teal-900); background-color: $teal-900; } + +.bd-cyan-100 { color: color-contrast($cyan-100); background-color: $cyan-100; } +.bd-cyan-200 { color: color-contrast($cyan-200); background-color: $cyan-200; } +.bd-cyan-300 { color: color-contrast($cyan-300); background-color: $cyan-300; } +.bd-cyan-400 { color: color-contrast($cyan-400); background-color: $cyan-400; } +.bd-cyan-500 { color: color-contrast($cyan-500); background-color: $cyan-500; } +.bd-cyan-600 { color: color-contrast($cyan-600); background-color: $cyan-600; } +.bd-cyan-700 { color: color-contrast($cyan-700); background-color: $cyan-700; } +.bd-cyan-800 { color: color-contrast($cyan-800); background-color: $cyan-800; } +.bd-cyan-900 { color: color-contrast($cyan-900); background-color: $cyan-900; } + +.bd-gray-100 { color: color-contrast($gray-100); background-color: $gray-100; } +.bd-gray-200 { color: color-contrast($gray-200); background-color: $gray-200; } +.bd-gray-300 { color: color-contrast($gray-300); background-color: $gray-300; } +.bd-gray-400 { color: color-contrast($gray-400); background-color: $gray-400; } +.bd-gray-500 { color: color-contrast($gray-500); background-color: $gray-500; } +.bd-gray-600 { color: color-contrast($gray-600); background-color: $gray-600; } +.bd-gray-700 { color: color-contrast($gray-700); background-color: $gray-700; } +.bd-gray-800 { color: color-contrast($gray-800); background-color: $gray-800; } +.bd-gray-900 { color: color-contrast($gray-900); background-color: $gray-900; } + +.bd-white { color: color-contrast($white); background-color: $white; } +.bd-black { color: color-contrast($black); background-color: $black; } \ No newline at end of file diff --git a/examples/brand/app.py b/examples/brand/app.py index 5fb93bffe..7457fc3b1 100644 --- a/examples/brand/app.py +++ b/examples/brand/app.py @@ -1,14 +1,17 @@ import os +from pathlib import Path import matplotlib.pyplot as plt import numpy as np 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()) app_ui = ui.page_navbar( ui.nav_panel( @@ -122,6 +125,7 @@ heights_equal=False, ), ), + ui.nav_panel("Colors", ui.output_ui("ui_colors")), ui.nav_panel( "Documentation", ui.div( @@ -266,5 +270,39 @@ def out_text1(): ["def example_function():", ' return "Function output text"'] ) + @render.ui + def ui_colors(): + colors = [] + # Replicates: https://getbootstrap.com/docs/5.3/customize/color/#all-colors + # Source: https://github.com/twbs/bootstrap/blob/6e1f75f4/site/content/docs/5.3/customize/color.md?plain=1#L395-L409 + for color in ["gray", *bootstrap_colors]: + if color in ["white", "black"]: + continue + + colors += [ + ui.div( + ui.div(color, class_=f"p-3 mb-2 position-relative bd-{color}-500"), + *[ + ui.div(f"{color}-{r}", class_=f"p-3 bd-{color}-{r}") + for r in range(100, 1000, 100) + ], + class_="col-md-4 mb-3", + ) + ] + + return ui.TagList( + ui.div( + *[ + ui.div( + ui.div(color, class_=f"p-3 mb-2 position-relative bd-{color}"), + class_="col-md-4 mb-3", + ) + for color in ["black", "white"] + ], + class_="row font-monospace", + ), + ui.div(*colors, class_="row font-monospace"), + ) + app = App(app_ui, server) From 562406415d7aeb3257a3273e567cd4d0c7ec68c8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 08:00:29 -0400 Subject: [PATCH 31/82] fix(typing): of split_css_value_and_unit() --- shiny/ui/_theme_brand.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index ac072da4b..8d0c5ee8c 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -393,7 +393,8 @@ def maybe_convert_font_size_to_rem(x: str) -> CssUnit: def split_css_value_and_unit(x: str) -> tuple[str, str]: - match = re.match(r'^(-?\d*\.?\d+)([a-zA-Z%]*)$', x) + match = re.match(r"^(-?\d*\.?\d+)([a-zA-Z%]*)$", x) if not match: raise ValueError(f"Invalid CSS value format: {x}") - return match.groups() + value, unit = match.groups() + return value, unit From 67e9b7dc170d617e17d36f47972afa993ed3fd4f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 09:37:56 -0400 Subject: [PATCH 32/82] feat(brand): Swap foreground/background in dark mode --- shiny/ui/_theme_brand.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 8d0c5ee8c..3ccc26600 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -26,8 +26,11 @@ def __str__(self): color_map: dict[str, list[str]] = { - "foreground": ["brand--foreground", "body-color", "pre-color"], - "background": ["brand--background", "body-bg"], + # 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", "pre-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"], From c7721643063b19381e56b3727c1f8abb720e83ad Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 09:53:40 -0400 Subject: [PATCH 33/82] feat(brand): Pick white/black from brand's foreground/background and make gray scale --- shiny/ui/_theme_brand.py | 84 +++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 3ccc26600..c3141039a 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -278,17 +278,83 @@ def __init__( "code-block-font-size": None, "link-bg": None, "link-weight": None, - "gray-100": "mix($white, $black, 90%)", - "gray-200": "mix($white, $black, 80%)", - "gray-300": "mix($white, $black, 70%)", - "gray-400": "mix($white, $black, 60%)", - "gray-500": "mix($white, $black, 50%)", - "gray-600": "mix($white, $black, 40%)", - "gray-700": "mix($white, $black, 30%)", - "gray-800": "mix($white, $black, 20%)", - "gray-900": "mix($white, $black, 10%)", } ) + 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. + $result: ( + "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%)), + ); + + @return $result; + } + """ + ) + self.add_defaults( + """ + $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 { + $brand-white: map-get($brand-white-black, "white"); + @if $brand-white != null { + $white: $brand-white; + } + } + @if $black == null { + $brand-black: map-get($brand-white-black, "black"); + @if $brand-black != null { + $black: $brand-black; + } + } + } + @if $white != null and $black != null { + @debug "$white is #{inspect($white)}"; + @debug "$black is #{inspect($black)}"; + $gray-100: mix($white, $black, 90%); + $gray-200: mix($white, $black, 80%); + $gray-300: mix($white, $black, 70%); + $gray-400: mix($white, $black, 60%); + $gray-500: mix($white, $black, 50%); + $gray-600: mix($white, $black, 40%); + $gray-700: mix($white, $black, 30%); + $gray-800: mix($white, $black, 20%); + $gray-900: mix($white, $black, 10%); + } + } + """ + ) self.add_defaults(**brand_bootstrap.defaults) self.add_defaults(**sass_vars_brand) # Brand Rules ---- From 2573e0a525adf81a836866a8b923891ce21a7850 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 09:54:10 -0400 Subject: [PATCH 34/82] feat(brand-example): Add color swatch page --- examples/brand/_colors.scss | 6 ++++-- examples/brand/app.py | 35 ++++++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/examples/brand/_colors.scss b/examples/brand/_colors.scss index 4c1c97bd0..618ba867a 100644 --- a/examples/brand/_colors.scss +++ b/examples/brand/_colors.scss @@ -110,5 +110,7 @@ .bd-gray-800 { color: color-contrast($gray-800); background-color: $gray-800; } .bd-gray-900 { color: color-contrast($gray-900); background-color: $gray-900; } -.bd-white { color: color-contrast($white); background-color: $white; } -.bd-black { color: color-contrast($black); background-color: $black; } \ No newline at end of file +.bd-white { color: color-contrast($white); background-color: $white; border: 2px solid $body-color;} +.bd-black { color: color-contrast($black); background-color: $black; } +.bd-foreground { color: $body-bg; background-color: $body-color; } +.bd-background { color: $body-color; background-color: $body-bg; border: 2px solid $body-color;} \ No newline at end of file diff --git a/examples/brand/app.py b/examples/brand/app.py index 7457fc3b1..06847edbb 100644 --- a/examples/brand/app.py +++ b/examples/brand/app.py @@ -29,7 +29,7 @@ ), ui.input_action_button("action1", "Action Button"), ), - ui.layout_column_wrap( + ui.layout_columns( ui.value_box( "Metric 1", "100", @@ -121,11 +121,11 @@ ), ui.input_password("password1", "Password Input"), ), - width=400, + width=300, heights_equal=False, ), ), - ui.nav_panel("Colors", ui.output_ui("ui_colors")), + ui.nav_panel("Colors", ui.div(ui.output_ui("ui_colors"), class_="container-sm")), ui.nav_panel( "Documentation", ui.div( @@ -286,22 +286,43 @@ def ui_colors(): ui.div(f"{color}-{r}", class_=f"p-3 bd-{color}-{r}") for r in range(100, 1000, 100) ], - class_="col-md-4 mb-3", + class_="mb-3", ) ] return ui.TagList( + ui.div( + *[ + ui.div( + ui.div( + color, class_=f"p-3 mb-2 position-relative text-bg-{color}" + ), + class_="col-md-3 mb-3", + ) + for color in [ + "primary", + "secondary", + "dark", + "light", + "info", + "success", + "warning", + "danger", + ] + ], + class_="row font-monospace", + ), ui.div( *[ ui.div( ui.div(color, class_=f"p-3 mb-2 position-relative bd-{color}"), - class_="col-md-4 mb-3", + class_="col-md-3 mb-3", ) - for color in ["black", "white"] + for color in ["black", "white", "foreground", "background"] ], class_="row font-monospace", ), - ui.div(*colors, class_="row font-monospace"), + ui.layout_column_wrap(*colors, class_="font-monospace"), ) From e05ab3d2dca0889a2e5f7308302c584c42f6e3d8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 10:04:34 -0400 Subject: [PATCH 35/82] chore: remove sass debug output --- 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 c3141039a..d62fa9456 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -340,8 +340,6 @@ def __init__( } } @if $white != null and $black != null { - @debug "$white is #{inspect($white)}"; - @debug "$black is #{inspect($black)}"; $gray-100: mix($white, $black, 90%); $gray-200: mix($white, $black, 80%); $gray-300: mix($white, $black, 70%); From 484c89b98cf3911cad2626b6b943f2a4bfa4e66a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 10:42:00 -0400 Subject: [PATCH 36/82] feat(brand): restore card borders in dark mode if brand makes dark mode --- shiny/ui/_theme_brand.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index d62fa9456..786055203 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -382,6 +382,14 @@ def __init__( font-weight: $code-block-font-weight; font-size: $code-block-font-size; } + + @if 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); + } + } """ ) From 51cf4f1c4c792d94689f200c5f412f68eb9c8de0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 10:45:37 -0400 Subject: [PATCH 37/82] feat(brand): Mix of font sources --- examples/brand/Monda-OFL.txt | 93 +++++++++++++++++++++++++++++++++++ examples/brand/Monda.ttf | Bin 0 -> 167284 bytes examples/brand/_brand.yml | 38 +++++++------- examples/brand/app.py | 2 +- 4 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 examples/brand/Monda-OFL.txt create mode 100644 examples/brand/Monda.ttf diff --git a/examples/brand/Monda-OFL.txt b/examples/brand/Monda-OFL.txt new file mode 100644 index 000000000..4a58f7175 --- /dev/null +++ b/examples/brand/Monda-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Monda Project Authors (https://github.com/googlefonts/mondaFont) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/brand/Monda.ttf b/examples/brand/Monda.ttf new file mode 100644 index 0000000000000000000000000000000000000000..458b865a31ecab97ff4de4726ca458092ce386e5 GIT binary patch literal 167284 zcmd442V7J~@HoD^@4Y)fQRziMI-(%u=pfRI0wSOyAP9mYf&wCfy>|&J*4TURJy@b9 zQInWvqA{9ciLoRxMPr0}{Ac&w9e1|){e3>)|L=z`?{41i?Ci|!?9A-G2f_#;Q~1TB zi1?V;xO25G#t3Id03atmwRd{Rzx;57b53yy_skI9$|Gc*me4OUZAtcu zZV11hiIBmSWcc3kfn6NJpLhV)u-@rjzNci<+arW8!1qDv8Ic*&Ela;E)<~8q5d|gZ$BcxtjMc${d`JK2P z?jS+Ie+su&N>}nJ8bXX)t0J@xp_T|_KTZuBQ8cCsok508M4~}|A2-|{;l=bm?<{PA zi#_7V6_h13pnm_GMP`6bya~T-DJoDGVLrO9U1aTB9;v?D3H#8I*A{E!qq+Adx^D1^ zRD?f~8I3!K(>U%AZ;k%y<qwDwK);k9&hLPv#ez*lwI53=kG za_t8Ekx4j&V{rfNiXE$GiRy*7EuTPNP?tZ1GXAqPBU5!989^6RrLWd!HSF!K9RSCx zN-OSGJXDr1z=aa9b?Wjnx}g3Hmbv^*MF>KtyQ9ePtC&gg``CCcFJ zP$u7irvI;&lc?cM_+_7>Y3c%UJE1ARf_;GR!c%lX1+(MR0N3k^Ju=eV+lLy2bx;?s zZz_5tQ3EafOt2}HaQ)vbF=#w_fDB&fV>*1F&PSnCRhiD2qYTIOqgR>RcSJtPBtOuW$vDC>wHG9i8~f3xD<@)PlH zX#%(a<*2H3hN~ICAHvlJN)ib{=i%xMu-~CP=I27)EMzE^md7Lu%BL-V(C=VBsjXef z*S0jOKA+&HFc^i?Vn>dlQ&2X6x0pl@q0XFj%L&e%!D86GL+mrHV^8l3#_W3oIo9$Q zIgkA0G3dk#WgmYDa6=Is!PcujzjVFOa$gSfg7Heb&Qg%U|AiuCX!V_gDs zJwUswF>go{9_|D)m#`hNlKW937jS^fEcdc6!dUR7R&hsyLtcq)qEX`%O2pJgeq=D)5K zAV1z7>Z?mGO5$HvvY?*&UMh?cK69th{5KU6#O7r;eQVSpUj#Po5*i_Yf=1{KLnC1Q z9U+84ISep+R44RCbN<)MAygnh;lBqz?io7rx&m}vQ5ARfXZ>>w=FTTV+yAZd41C&7 z&`&7%?XN4n;r=M_SLsz%NFlnSDgr5NxdEjKzMHAa7jTV&;spinqW~yFpy=~<;Cn(* z;}PP~74C0Vj;OBc?^pDUHTV2v;5*SGWWT!T*S-Kxf${vmP|ktOmxBDDP}j=`$FLk zp&ITesv)N!oS%q(=ANUU$slx`8-S+rCCFL0h)(mCPLW{(5koydD2%*6OkNQE~ z2wLbp3fK8AwbuuIIS8em)=~GzaM3oi(Jt^K5f2$I{uJ6JyMuNCZU&?G_z{peNJwL0}6flBvLlsc0fM7VRUR zupYkv=WU@a6f#9B>UwZpK>Hv+fzKC&HUO;~ghp8NZUBx|Xd8DM&6R^2<0>sT5qbVJcTRZW_>_ghI zQ#hqrn_kL?QE(4vW%q$LWidH`!f*(bAEiQ$qA<<{g^_ajep~uXui#6Ekt8S=Rrj94 z{o_!EOYLwU)R`7;8JfzOp(P|AO~pe{I4dIBfuVr^ohgG{0{_E^ff<@n*aoAIDEfM`BN$h=K%? zaMGLfAsM7U89=hhATpQ?A*Ey_sU)MxL^6%cCyU4uvYf0Y`^Zt!NY0Q?$W8Ja$8(09 z3D=Qx=X^PTE|Y8EHgem!M(&L51l#v)KeD}Kx5RFl-5R@p?c3OQus5@}wzso)w0E`l zwpZAP*vHtX*_Yeb+Rw3HZ@FpvY!lG*l526xUkcHKXBo=eWMl^(jBrL>VXRaaKQ+;V5Q+gq02g#11?8g z8ePt~dnJ1)`+chwr<}J=Nym? zGC=Y>-~Vig&^=3pZr{H(5}`Zpcj!(9`?+I%XWgCrJ3Vgixl?d^2mGtQ4YIl;+|i@A zZ{KIXXWX7~d*tm6w@YuWzXf|9w?2XL9+Y)Zw%%HGtM*nlLO%r2y7cFV_CFZjNI~eU zUD9{;?|#wpFX{ZiFki48-hxkYgbPyZF`vojKrDv%Y98zbzno{IqV0Ja z_p=`!tP3rzKX7j)zlPrd@I!oKEBwVz5Pd@p3Kg!?-?G`VI@u!Fdw9idn=yA_MAVV5QVWx+@zGknysKvdI)qe^@~bZ{(x-dB8Jg)D`)o7&HV8Lse)pYCy|j9_<6G zd>;LV{zgwR!R>KJ9EPKCPn?Vg@Wp&Dei(nAA0+tTLOct6HPVnO2;obxJ zoa5_-Fn$q#hPUBu1z-MCegPjVbmu#AjrreE}Jgd$!S>h*r=UMcHLk@)xC~FjRd^Dfju+vh zcq>?pL-=>l^!xZB-b}iX?!=S$kW>=HA15hbBWIC$q>daTS>yz=#uLCH8iG3FiO2)j zgO|{Ne84O7#0ya{%$YE}8fD@WC=PE!>G&|p#wSrc-j2%fXJ|P76ph4Rpb_|UREuw- z(fC`m2tP%2_)jzq-$U=T_ZivA7lWwB-5}p_8`k} zAnt)P$SFJs;*-Is2!F(#;!blXxicsbMd3|oJ=#S~(Ghf-xFLO9il*aV&;{a+JCV7# zGg*wC$vfDUEW~bP5k7~E@n~d*YmgJ3iALe8s1#pDD+xw-NH263R_Q|gA#OqeC=zc( zCHP}>lDHsSJQ+FS8R!?%8+E{=kR=|6`rw18Kdj<2@dLB~|A9Uyf#?Skhn|xHB$8t6 zNEToPS%dXSB{qd`;+(aJd%tz;C1*mzJ;IQzwuyVPTG>8uwu`E z6>0<7PId~hLbMPiL<;djoDd`U34ubQ;4cISK|%sQM+g?Yg%BZJ2!;LJyZk-=A^$u7 znE#c(!{6rr;(z1s^N;wy1v9}^Fc;b&J3Ixs;MvF(&q3~Z0rG-<-0pY{_$X^3CRvX{ z@Vh7!uR>Y45e>lap(MNq<>Rv`5${Ap@kLaLzd_^h4`@92N@MZ&s0QCa)%ZHZI{%>A z_z_wG26+|X&>A8`+ldj{L5$IEVurSmc4$AbLI;R7I!HRA_lOf}g#C&$#2pGD=c}TF5YLKt|y;Ovff<0(K$wIFxL`4rD$KA)B#3S%)LY4jfLl;RJF3N0Z$+mh8oGWFL+v`*9LE zf_ss}xDR;`=a3I^F1dj7z{fMiV~_;Li>VMFXt=yQG5lzj9Bgzm?z3Z{hdy z+xZ>*9{vjd1^*@g75^cBk^h)K${*xE;ZO6Y`0M=l{CE5h{0;s`{wBmKJBbO}gZ{!8 zPbHIZA~}dA;GfW1BIjrEb8$ZT7)`?M$QbZUHwit_Qsj%=Ao9IUdJ4USq+_^c#y)-l zU9imHPB_@@ez5*jLhZRwcLQKP1$O%zbPHCX=hy(7V@K?Z!(sjD2W!n{ydR&$pW<%` zCWheQ_8`5;9I(#o$z4vt_26c3=eW4_Kxgb*;d&>*=gCwva7P&vfpJ* za=F|>?j~2r$IF+<56i!i-;)2P=c6}NZpjr#rXQjor=O;ut6!pDt$$ws zZv$k|%V4mzI%eS>}+oNr-w*A}Iw4L5|Pur_)|1snY+Z%Q?bTiB_9Ar4laGBvo z!@Y(d8a_5`Zl~AIw4HrBk9J}02DBU2Zgjg1?cQtmUAuej{%Y6KKB#?K``q>=?W@~Q zZC}@ZW&17d546A9{$BgPI#_gY>JZmqYKJd6-0X0_!xN)mqgbOpM)^i%MzuyWj20TL zHrj1;+~}gw*G4}X{c0Ry9A}(noNHWSTx~qnxXyT`@ebn?Cdj0%iH%8?$y$@0CPz&^ zGP!DU&(zj5*0hi5K+^`()26?fd6`w3tuxzYcFgR&+2>|A%ziO@X3m>;Fpo2zZ2p1y z7v?{i|7xzZ(6=zNaIo;S2)0PHm}GI;;zx_;mNH9YOFPRhmT{J8mbsSWEN5A6vb<#Z zjTLXz!OF&}vz4D!gjJGNj#aT$jn!I@CJeI^BA( z^+@Y|){WK|tgl($v3}N3(XmIzl8)6K=XBiO@o>lYJAT^n+m3fT{$XQl6K0cOlVMY4 zQ)@HBW}(e$o9#A-ZN9a+Yx9S#k*%$*pKXL~l5M7Kp>4VCc-wbv580lv{l>1dou6HV zU6NgnU9nx2-DJD@cFXNH*&VVwWA};OHM=`@k2|4GZ97?Z>fFh%Q$(kvPIEgg?X;oO zo=&GaU9$ItIAJ$L1RvNpImjU@=~?8f1LxIqnxKY*Ez3oe(d7v;_DLb($l4%%Mh1Jmx(TOT~@kmf#~6s z%O#g@TyDAi=JMQC=4$L}=i0?Jz%|Nsmg`p6pI!fS72W*YO5E1C?Qr|vP3&yYxliYT zo$EWV>b$MzdHDZ`b^;)4HzedbsQRT|e!5v+Mn?|9S`>Mjo~v?jFG&DIUchRUXqlmV0dSIO%cG z<7-ds>E;>j+1oSQbExMi&v!fzd%o{^$5ZTP&@HlCLAPbyHg?{fq8+$1IGo<3S1VrF>r6-dx0MXeie8-$T_GqXjagU zps#{{2znUwPcRqk6&xBIADkXMC3r#bp5QCNKZY2FScSNR_=I#1Net;5QWR1VG9_eT z$oi1oA;&{L2>Bx9r;tBFai~$KL#StHaA<63-_ZQfRiWEL4~3o&{XFz;=pSL0Va{Q} zVcB6r!$yTo3Y!fx0aF2+M=o2w8Vpv3N#EgiA z5vwD%M;wm$AmWRNn-TXToN9~R}9(6J5S#*bJo9NEbe$g?}snI#nrO`Ff)1&L7S4D4&J`{Z> z`jhBu(RZRBM>ogl#hAv}#}veji8&H;Hs*57^_ZVy{)`c0&13yzBV&_e2gDAE9Tht% zc3$kV*p0CVV^7C^9D6nPcI@x5O>y$L&T)Qm{o)GZO5(=G&5TyAmahKzM zj+ez7$9u#F#mB^_#^=Nr$5+Kqj-MZYF#dl0lLVY#m|&IQp5UJlnUI_?AfYH>bi#~; zg$b(@wkJGJXin5iG)=Tm^hgX!j7dyQ%tCYrF*&x|GIXQVia#3*Sx3 z|45OixTpB1M5ZLC3`iN1QlGLaWn0Rjlrt%xq+Cn6lkzyFxwm<5$KKs~hxCr?-O&3? z?;E}E_ij$*Q{7VIQ*%?trcO=Wp88eluYJ7wB=o82bEePtX_jey(}ty8N|&dnrDvrV zq?e>urjJdZnm#vuQTodC4e4K{Kg=-CNXr2j$eariAZe-of`Yr39Y?Q74(m&g5pX}i5$n2ik>Dhy_YqFky*`Bfw%I=mmkKjl29C2`D!pNeL zQ%9~H**Nl(k$1~UdHZswa_{o+@?PZw%FD{9mM<&cRsLc5wemaVe^$sUOe;E9gjb|j z6jzL|SXR+k@nglWm88<5GPp9XGN*Dv<%-IEl^<1pU-_W2d6dJb&`~*~YDUc%wPMtv zQJ;_cy9!qsR{2zgSM{tKR5h(?OV!QM=A(T_r;MIBdjIGTM}If^k7};EeYIV+XLWRS zW_4-x^y=-^pH^S5ZXRPirt6rfF$2aFjTtj$;g|zsE|0lC=4lOA)3(N}#;(S##=9oC zCaxxeC}9c#U76Ke}+R6M+mcIoDy=VW7sBQ0`fh?7t&ilw>)IKr+fmB(du#Bu(}wH4 zr-8TEhRZi<;I`oLx6<1Rx6y{{`D^NTP~&0rs_VDX`?_{U>UNY~z1tdm#yaq`8n_Ah zSmFzLMC(b4G1*yb9Hrcl$pUD@`d=f_+;WgN48$-YH-FZJlxVR zufWGVas1DDYxX+FZJhW1p`+%HejwczUac|h24LRD~AXVURc@yI85F7GII#M{J7{F^czKW!j0 zpFie=jY~zlrAl!rehOn@xR?ewuo-L3(LFddobpnFJ5V@`4D!rETj-nSKi$CE7&-Ww zo12-+?=as(`<5@jYEXSj= z3*G!@jTLVSTLvDOICQ0L2g9OO1y|26@fcejRhu-&e^BJG5*JDn>swB>-BN%=wH zp#4CT2JXiA!~=YY7OkL}Z%HkuFmie;iZ5Nd^hTnaLPT4{w>$!#y#qWmU_A3h$i&T` z&JQ`v0h705;I1?2yJwn}#P5F_M)K~4K_llxEX&Dj+Vw{I81K&O@Xk}kJ3Ez}ImngR z(^+i->h+ey+L5)&{KmJT6#rQ-u6ZMgRNs$)bukqhnQd|GM!>j)6NNA6o0*(xnxl-_ z5_1JrJ%5FnX*)vxJ$z@Jm*=?KM>bul_BtKCa>}GFX&KumPh1v#+P(a1^PaO#RNxOs zy(5U0I$(@7WnWB6;dA7o_c8aAz+0N>4s#~mYHa~y|%myf#&Pi~(f=F;% zB1&-4i_)rrcOlKKa19ah{b{d=#&|m>Z*ObYiT+wOO)sag*XU@RNne!BqtuqJ#e6Fxd26~4Fz|%Y`8)y(B->8DSNpR33TfMpL zz8UZW=zzR;w7R|!#ylp5N3bQ&CAjh|tEa**xU2N^x|pm3C#w3d#!GFngx8Y%E8zuO zO#4^E-6$M!%m$ujHqab7zRCi=#H?16_X?)znz)x3D6I%G?N;`f1lzBzdxAb<-+cVd+W~!oUNcVv~IxX>^R6c3C z=Y-lFANB=`#sbF-rI~<@z=>29-mtlhT!M*Es*IwZ@=~I=6yJcO4y`S~=T`W`LI5++ zArPBkHwUw^gcvCQhDl`QfUk)*M&$Q*$Pgbh5dxeg+2y~V} zkE?*|&w=ZX$P)%YT^S>ATAbJd=AiLu%)m_yCnn6RaRA1fljBEjkMr~zcjxe~Z_3@d zhO{k{rmT!U?Nxnc&E)MF%C6DNg~pV+_eY5#6(?r(46oiH2b0u_Z~4nGz+Z3!NpxQVzX}2GBg`7d4GjiIn7n#?8bXKG)H%4@Dd9T=PvH9~E24D@$}6?5!20FnO5GYYwpDAyV(?&uhYY99!4=S& z#>uH5cj~eHx3@x--^r~V$C(~}I6c6B`okl8zpM5*)_wibNe6QW9-KU7Q?j_7IE=8; z4;{8z%cYGRy*kIdy?OaKAGJ#hLZ7i%>F_kPAi<4FgVDjP6+Mdus|VMP>8k{%R!M@} z>A%ry<3R=ABC4&DWfQ&Zoao)y;Ar&d+MV@dH#e34=BEdzD<@}Iut%M4od zQ4d>{d->md^6@c{oXqX*JHC!t7|jEnDF>NBMC4$ERlzMpZe~szBMITJJkW?3<96|W zUSme=pZP)Y4~G|>D)k&c`ZGJ&GWJegni*CUKC{r@e)-X~xbkV;=UlD*rnhL_$`fkw zC7w7io=7>Bub8grdfcz`Z8gjQ1NK%LlS(!zbKY8Q@Za@RQddAi^lAGxp#Km~8$qZH zzn!jJB&RN3R7SxRQcfY0o@bH8N?2gP#z1{=d%#NvjbSmQR;xn6`}ybP>ZbgmdOTID zHR#I~aV+f(cmrqRK~FtYUMi!4SrZ9vOQRyFzXPH#?fP9XXcWamsn)7*qpB~#>FO`x zvCzR!^;Cjes^Q?>YU6jKaLN^k#wb^4Mn+{7fG^TW!7gxHsbK)_(4ngA0=cFHKY|Un z9oPoKJmlSg!vM~h>0}7B7^d-wH9XUV`Z!ICb&{Mq05*MXIf0aRT)f!4KqDmTd7c9I zd=JzFi9#|7v`AY2PGM8=5qT{BicORqmh$74ENPmtl=AXa%kO%U|2&z>1fFFpWjtnh zm`!1D@JIu+;MAr_a6271wH*@N7EU}d{FELhD+z9+17D)4@1%o=%229rp$*sjPz9&C z)Bu&o6oB>UI9NYa#!@>=bvto8<%b6DriQcnr!;UY9XR#yBs`8f@YZoV>%i%Jm+CvH z;bI8)fmXj=xjl&L7PvUvOpp)E9|uQQSZ|nwzztK#$+IonBq62eI~eXh$n9UUq`BXS zd^k)>G;-C)8U(jHC;G3kgabhsl>=>U~OT7Ktl z0F^KU0wEPBt&FgKf%ZdcVo0E6WkcdSla*J=pWpwTv3+LTyKP)-7tiNXHaF{k`j@Rc z^vEzUxiqO7mucpwE0e_~FA~nh z)z>T03)BNf@GRh59G!25fKkq|=?}@@K|cDxHSf7f+ow*{o&2Xc}Zo5fzjmga|? znKG)hc^Bm?YtLp6Jqb+Xx%-UY5}eKw32qBdd9(Vk!a{!8TYI}x=wyhjx-LUy2q5z*BnyDbbKqTOY*yh zlL3axHTB~sRaK6wmQeK`m*weIzo58y{5*U`O|e7)NRUy+KOpy8K4APzrj)7S0wIgl z64!ydlO<{m(T4M}@El15!?Rr*4iWVtRzDgdSPwb1KeS)E_XTj!I0wcVCO6s+=7yZ= zGL;+sr^O$Z-jxRSxPDbVSE(M6xK?H_{G^hB8-7k@02!1!f;!eeuS1b1>zTCBXUJ%e z=Ew%P3w|-M*9qn}#6{GOC`@HoV(+=o_az?{33A=BZO?T?`l7A{4n_K z)ouJRB2tnF^3#zrgoM&4;J~pme~Z-cBb@TCcz_$rB+c|pW(l-_wS`#%JE_#cv4Cr! z(e>j*FNkmlPFaW>JL^7+7BIGa!BF<0AiSq+yx z(!ed%aB(~BPXl-3D9?b7mPfKfK!+>LA~2x-Fk_|2Tl0y2aPqgHVWX>?Ohy8xu86#t zUyxJgeW|QqZt&5aR-a0~dVMTmX0lj8=l%a&{6NvX0QV22MDbpOkB0<+S1MoP8^P#!U&2 zC1~d2*qMZAPN5k&#J=e^`Cz#KtFmnT= zO!w13=lrBCQl~5D%@pI-58wPj|BL&k95@UPc7vWG-gW5i;^$q2&&sMMWi-7f=@)R8 zuGbPr>^ZPh4F5bK3nT<16v8=we@qu9OtlGe)0|aKeW8Ezz5Oe=u>EtMZuv#eDq_ay zUfiOleW2Xl+~?1)(BVn32#s6K=$@Z$T)k+c=VwL5%&s-I%&{UdvCO;ji* z-P$u{YoD~OlPB%V&e|u%93PE3Q}R3l6TEqI?3|Fd!0D zy!;dN>jcl6Nj=k?h_w47_3RJ%61w{U78y&MKP-0(PcD(*3yO)i z_5huVijIn@*3Yos!djly9iq2mNT zysn**x*g4eu-vVL&sYb3R>L0?PA2t3>$BXgRNqO5pRStv7TR#VP!$|Je@=kt3RXDi z$5lNZmfMl=S?aWN*R%sTtW8ooQ`(M>mt{4ic5J*Fd^9qU;8xoB8dSfdXTa-CG9EI)Kr44WSM1VMX7BeM`=F!boBBG zkVbAzDsNI{o6Tt^*}xK##?(d|=p>X|)6J4*cNbFGo-DIuFYn5}#*)=YJF;GHFlE~N zvHxYvn52Ocrcw7)nhbG|S_7B{N}8jifqGwSGy~x5 zjF;|Ho6`eU(4!y?8pWE?^E4*x1Ou3Pb@CXjyaYBr?C3kYXZ)hU%GH!-H1^uV zc=?;eO9Gh*4V-3BB)BDotM+<*V5~HgMMn$DratZRYGO2LtydA%y48!*UM7#w+#bIT zdTR%(C*&l}R0on|PPzlWatT+DJzS(UWQ(+H5t0DErJmZn`%JQU7~knNv1k130m=`+ zM$`C^@{ndvtSJ{E0(v#^s?#p7U?=ZdPddCzGWDm#$#iZ5f8hCP%;~Q6GMU_jNlG(a zsZwzJe*XGSf3-)Ia`D6|@e3`pFHXk84W@tp4%V6*v%ar07H{G+FMC#$g5v)^vFC5} zRwwq}seSEvz2}DXyxyXPxSREh2lmui3F@6P9z%>Loh5^egcEi!g0JeMWY@_Ikey$4 zcdYnK{7DSLJ2G=!sQh{eTYA5@Vf4}g*2?K5Q@M>ij;e@_n>j$zP9ckF?NgQ3s-jSs ziCN5Rk>IxEjHJyFYXB{v%@W+5{GirysJ}!5cT(?MGdxuLrTUg?IINZ0c--I}IBd31 zIJ4WdK8?EpPVKh39m7Z0Itib%8ZKVb!RHC5ap-#)XnuwAjsxEwJ~It{JqJ}<`Z_j} zhxN+Mui+)=>vhl;+Lr}Ay8&m7APcD@Dp(yAa;{Zg#B|N77ep4DPOii5(R z+OiuwB6&GKTM{2#$Le8^bUw@x4;ZJTvs<7!4=XU+n{;zgZ&1Syk#v;Q)Z9W~=MZJ& z;Ck_KWqdkDGpZ+eckIa&==;hQ7e5?!lxCx zM+8__ISiOlFmFrGV2{;K-P2}ArQ475-Lz@OnW3Obj1sCzn5Z;qCzB|H>#5;xkh9y_ zat$PE2~R7p)3gJBTGh^$!21K#aJ``_xEq|w*~QixOE{~LNbieYbmW?LUC7GTc2sj! z_-xeplxKD7yQ|I&NOXgSs_I)372S{l(#G#bAj!r01vvGKS$(RZ0B7TYb~Kg*NhE<5 zfDS0YGjec%Eq2Cq2Pc2EY@D262cD*6jjlX}2faPS4Z`Jb{}Eemd|Np?G;G|U3LNJk z7QFio_wTKbmnpw0F!=7H&o6BD88L9sxUgbk;68qKGe<3hTKXKkVFQ^!<_7-{bl?=XTQOI_n>Rmiff1kbeTs+mfM7G8v!|EPH?@co#B21y9z26Ot{# zK_j&5yOV){4`^y>!w*uOP-bW50@c)aW**oOi7NGhWN9cGx~OJI{aX^)6{m2}B<+6O zNEXu+Q`MT>f%1n1=aLUVT^`c?SV=rR&dRl;+)Byo%1*0NrZJ544G&<2RXusCFlIoW z1mH9;C)uCYT@cP|%_@oRIIuCyuq1;FPJlh3vbq4#V+gZdsz^&ZkuGI>s6WcJ*?)VY z&!u6z3Wu!Ez2r0Yx_Qd%gG0Dao9$-I^n@dM+^uFs;la5nKpn%yEI~&mA8Q)F0AKq; zA9U}ILjY%)ZWh14*i&C2^Z7P%fGDJ)>|z+@jM@ zXPtHsn=yQ??SO8n9hK87@LIw9zv#0MY`pZ@h!@&BsPDu2sy#k8?S8dn2Kcnb4bi!3 ze2x?jvV?wQQ4pP5&{O78Z2qDgb))eY)d%;mlOk}t*C>+Es0z8IQOTkhb+FFCjJKvQ z1ZO%GyGfjQq zdMh59Yf(uB;A{;9t-xk%95Fgl>B{RvT%bM)vjw@c)g8{883zQyKAeMDJ9vx$4lk>h zdOFSh&CQL?ObO2kAB)Z72X7cCZpK#=CML&hU9x0rOv;2rd{tbTx3ze@*la!xJN>a2 zTb$|Yvw3Imj>nr1-z+M+d3f{V9l<*{`?#MG@9q6VyvcIc()@v^KU5s(q&*@UA*$f^ zs9@J+1pt%Q#-Qg%VyeNf6bL7aBEva1xrxUXum5U>K znD$OfOEC?PP%gSnOfw4HQ#Xq_eU_IdFZr;6JKl7zd{IS2W_EUFM8%?VzI${0(hnMv zOPBYddPbkz0_tE?VNCzr2+bc;t9f6bf1vHW6cBQu)wkS&8{(5HJocKhyh@yMjUJ^W$L5m) zWzKx%Tsmsjnm(I`donIrkX4Ku5}b}&f?HBJi~{^K{weeZCry}kLq|qV#9KsP<1HR| z+h-zokpyuglpV?4=0(a2&;x71_>s(duvYDGN^mL%32s9gSkDriQY67Gbm~)zB)Fv- z4qgdRq=CDs;gnLXeyu268i61OIT%xK1;}^O5`&RyBwa*BepyUVn@*9j&fuYq>X_-DsIXfh|p+Dzm> ztQW~E_((04gx8+D!^U$6#z{};fJ6c@wW)MR;SkxO9L*Ig`;#NhrAztYo0l~$-At)w zwHa5yr=<3bEMr`k;8YqC+?IelrK#Tvx6y%9X-M@g)p$S}K#d0OM#>~S;&~moCxyfM ztEQfw8$gtDPBR8DhQBIJvb0CSdDScbQI7^>d4fdY>9Wym=UQRfj*~le<4;`DYiDBF zE~u@kQCpd8bo8YwCn6JuE0~ z?Ln+epN4dkw#@>KAl<``RpT#4FOKc8cY9`ucgOU;B?TQ1fBE_R%?Vv&%w0=zEiL*? z=@mRazrA@`(YR6R=6z-)rIrowvF;om(lxG&M|@W=@747KHk65B8Q%5jy_{2n=KJ*V z!4~H6woX0cQ#htaF9-%9W6 z+8L?aF?#bg_>6Vn5gNFOPXDd^eWG1opz}rI`)zHwY^w_H#=~2RsOQ8#27dmf9;fh= zrrjNNJ31adNCRj5SJ&6VcL(qtc@dwM{O_vq(Q(Q~Yw-Q0Gaj{^<@`UiJ|d_A>gR*5 zThrVn_y%;7ggtX6J?kqecWd5|Mt_jMB(5KSbndgrnFacejVhH+&)$_Xtjc`Mgpt$c zoA23--(7d6YfrCEdz*h@I@}>AqCVW1B*i9Y6^MR$@fz6+hcvW4R*ymS<%3&!A|T$e z<(Y;j{WSG|Q`cv*%~QeMcm`*4q7rwb?UYI#9xIuS^Du|(0DhaTtiC7$EQ118Rwok+ zjmP#soAab>hnokfpAU3mETEskSvF$wLFw1 zf@DKbB1%)+v2Y^UF=^jOWyUPn)7|D~X7sTH>Z{Y|L8wuK<0$knKp;I{pfYRpc?PNk z`0Mgzy)1igT2L|6Hfc;rkuBJ_;>|W*E6e5{%~nn?8IeBJPqxFL@1BM4F0l3LR=;ZpMe~y+E znP+5AX$T)*fWr#IW^_*U!L|nGHfC0X;|HV>y@i7!QVT%3Y@BQ+OqVPnHpNXgur+^7LGj3Ej;AjdzpNv%M-7(2>HBnP2QElN-TP8kM;Vx>(L@RIKbfZBPHg zGh0Tk9akLH;O|~#9&H^j{3IILx(|4_P@G({qoAQIA8*LZ?qaFzXQ4is&)ds8fox_m z+U~&H*1~~i*n6flXy8qx7Vg98UC4`!6+VVHQ6iT>7B|uJ5Oi~rMhtB7!C4YIL21|^ zhfoR*O0X?UoapJgH)qxC@bwLg#tok`V{GG{n^!J(Dd>A}R>kE7gWr#z)X9C^xZXo2 z1lJW#oLo3)*uP5QUgMcV@At1xG;(%7vngT1;Q~Kx{@B3!r{)iI$sP)Yy~h7y)S@jx zh7O7Z7saxkvkzsx$S=P3;@Uc!jJ~UE-TS{gr_aHt#9)G4>iKnMsq*H zOtx3eVwH*3Jk13>P|sQtz2;=b-47-;4n62GB(rwxl*)nmo#lHBGIpC6Z7skJOOJ%E ze8(uiz_EXcm|vOGu}|vQMG@2%vp(1?qFyKUC%}qm;B>u};E&)PgA6WuQJOUH7I^=l z2Cl*5#xwXE@I6%Hqhn+WesG}ajMbq3S#hspf2!@QgUjmRQRpczNrA1#@OO0BisTG`pAtg6 zK-IYg=4`&A-#>^=mh$JPuM_$?VN0jyS)aT?^vna`o`O9zruqeY+0?pN(DQNxpL@z|xkTq3lEZ9y13Y1%O@9--VN~r| zZ3H^io|W3Qve42b%41k=0$W*3f`F@x%mvFpk$Oc^PRFIrw3qm7p1XdCMCODOE9){U znks?S4IU<~tfrh6&udcz7EDDE%%vhVMJ(?QjvW=T%ITw0r|}GIS8GwkTbERK>#{3L zO&4|elutAiiN)iFVJ9Q6SPfDgn+ zU+wklh2k^Q`p-43&RLK*HDT5O+q?~fhvW^enip=JvAw=uvA0Ebu3g!J`KIqJ#z}<@ zeY}j8z3=mh-$@J8$)?uJ{N@H<68~5)E=ZhP7P+8HaZdb#y14chZPN?HZ6$qF(&l~x z8*N21eyUmYl0)T+RUxFAS;OT!*DoszhAtg2)JJcNVfwDcYt9ZJo4S`Kme0i1xMJyh zJC`*tnNngHojPHfcji7kYhgr;bxOe`l}-MQ+1M>?Cf$Q~+p2B#Z!DIR;J@m?sSTIl zk4U0whIvt%HSiXA!?D_ix8k8T9N-|2*Wr7p#s`+uf@Qf`KM=F2=n|8)`+=F9qUvWm z_Zy1|X?+^YvCIdrg%9r27*N7zq{avR*ve@vC&7(d;oNU>8p}y=lUBL_4~^v*oPVNS zpT%+#{I(99S|ka6M-7LWuFWq-?N$`KP>obp4f#(8ac^(sLr_S;jRWWe5LVl=y&SCSoxkK{Dhw2BHB`uoayI_t{ z`~dgV;K&4y23L?=54F`MQ_|VhF&&o z5{qXf1qw7*#vJEuG_E$wwGP#rbh`1>WJ0LLy%qJe#FOxMx zmu42Xnug1FDrSh5-OEgM&BAo?ou%*9gIS=~;s4*hFiI6mxKrSp*@H-76>-x%Z!R4h zps7kX)xi>CvhqZ((J{S{s=Ns!#%+nfJxrAoIR|m4#nb}>i%y9peLvh$JHc~K-Me!d z@IBYr6O_-wa?dSqIF}Fei}l0o#0S7nSQiM-^m#h;|7UCe(z!GY-8#Vg0;uKJ@{7G@ zoh}-9aEgVvlXFl`G{rq4wu~D&KCdcNeyl^@39%&qT*LBnxnV?a!faR3s$tH%b#pZD zFc<-h{YBDN$Q>$L*KY`z>9Mp<`}eDq6=Sib5i*JEnX(?NYc8RB!P?6Q!~DI#=)Xf8 z@l@*ViN!#F5X7NgjDv70hC1^8{w(@duX5%V^sQ^~=)Ux>HT8sDf}I;7 zrmGkCjl@Sj5!-e5^zYHb-?O{e?h||@vhQLsclEr1ACKa=<}Je(_Dqb5O6<9C7?;(o z9QE?p3$kUoUKrnNWMC$z=0){lq_+}A?1DT8l?$gWl1yEZlz~XRl0B`lrR2_yQ%lB< zNu~f2Wf&b~I{=L|U!ncXIt1g8z)?LuLDK>7)H;5E+r$kqKpUh*>&ir9{O>C$eZ>9k4d@Fb6M)A zGoU9`@*56kVe(s}-Df?^LOK`hoI3ePWxxl%<4Ok%b`6Nd?eNdNA7m_Ap51*yafDm; zib{L`=sZ8a?6Azm>0%@8g^fmvX5mRq)tNwcj9Gg$Fc>pBnB;CR45oa_XLc*FUawg% z4Q0;frF(iG(8!wR6(w0eX0nFJ0c1T{HQy9Wn_naCSI#&KnC#>&W?djDQ!W316L582{4HQR>~sL{tw9gXVMT1sM$rd{XxayySf>+>UMojUTNB?H zI5;Y2OmL&{lk$gvsPy?6%}rc;|7ZuVRE5$2tP1SGGfW)a?KPoB_|^E_iIF(@1)TiJ z#qcLFUV#_5dO{pa@d8(`QTqTJ;)QI6GRo!K)o`%R3)piW^Wl_}R(-w&TfMUWLsfkV zkBbJ+ZTL$I^j$v^obE(P{aTXw5+2B*Y2&AVg_Cg-@;8)|o#2a`FJy4Fc&MA`BU5Y! z4)N4i@l(Z$uzlkB1&)1}jV7ULrfN8?{N~gn*w$*57W6^S&b^cz(~{c>bwW%>dg?sb z<(DOh(H@A?+JVIdUBIxRBt>XCr&hSMZY!&%t_58vjHJ3C7USlCsEm0ToY4G-UyMVy zJ__>mc?@vxW5BThxZ#TLn$orN4m(l8Mu4lK4>+C&`Uc9?$U8UPpcZ!>taf^A9k@&7 z%-_S7a3$1NBR(=YoY{g;CJMH;(x*8YeH4eutkC4-klBgjdi5HYxV5oSCQm9XOe$Xx z8M#1Q0J!1F+rQygABgm%ooo*5X`q6WL)(w9&i%Id+T5#WfR3+l#`EXmuD#IYg%+HN z!o-fw_#EXao6+b(4wWK%k*AqcSqIksJLAV?gpHte3J7`s{62?g6p;D zxT}K0`2hSQiwY!se@XMAU{tW1Y(bNr>;YQPK_^4o==9Ewz3PWX zePEr|m+#zkd(}!`iK4Y+HS-=NOpkE9ic($pLkb6t`$JqyrU|zo%L`|zB#%e)UIK-$ zsz1sbOcqU=S&z%xij(646+tD9A>$0riffmQ8MA0cWMq`rOunG$qtb>X*(hKc?0*B^ z5XNOAdPanvE97)WK-1W1l;5$fL!oR}`n3*7RtdNy`$KoR*?ACp$N>%-$muM`O_#+! z`1-fv^tG@+GFXy76l#iT>`FSOxJWF(Ugwbdmcoj20OJ1c+Drb}x)phMdBR5LP-5!D)>+Jq`6 zY9WI$()_&^ZIJzo&~)hpY-e25?!^ zvm>ze2ur=r3qL7ZHx6F@?dD>UgbzkqMTOF|c5$*TF1BJR?jA+S9dFu+yNrE_lP_;` zGQk%%S2CI&N>_3aH_zpr=YBMoj=!^ta!&pJ23`6)leWsgm$nXqD_ATp(;5fNj1z$2 zJkV=O&y2J8Ca@4o`^5=5ETJz@2vT^);iqjyqI!cuqkZ2LiA$XR3{Qo3=nVEA1?BbD zu7G2}WzHRXQgiHq%DaydlgUl0j}j*Z0)Hosa`=?;ER~8H&WpH#$(%~-2f)L|j1^{1 z>{)4uvLu-i<4->ocWI4z^<|wgQ_S#AE^W+B@K@SskM>;D-3KHOC3 zua(oJY(@qU2ceL`8*9bsI5H4`nto3j26iElKHKp7i1ER zEq_T8S93+s!?2csCiv-SfWx}#BLllZFywlh=QC7MPn8F%{vcu4(gN~9up6* zHfR<(L-uL|CH$Ep)d$LP)+1d{iL}n%G~JL!#NNd~t)eyXiuv}M z4OA^3k^1(_M@?Is&=^!0l~WxAzSz)!SQRh)V;tc<4@yH74|r?F1Bk|{k3wcr9 zRKXcBS7Mt5><}Uswbq6hnMk!C5!i*MoP@V`0zGOtsW9a#rT1JGQ|Xs~G2TixD47P{S1 zVMyII4dFNINJAQrhL1As@l)SO#joGtZ(K`#OXG*AL3k-J(dY&Bs7VnGOjOFC(v7fv zyZ5rVBv?&@T1%AQ(!on-c?j^=h{irF!>igTfe`}M)^iQ)2v4uI@AvQj{@OL~XJ)>? z=KrJaJ>aT3n!oY0dv38SB49-j5ete)6#)SiyMhWLDhL9KBGrmmuwX|N?8ew)i(R9M zA!>{<(G-(t@}xaaVl0U*nrb54!~2~*=Uy&|Nq*)1fBvr@fpg~U&d$!x&d$!x?xwF= zm7cj`h5g8liR@+UX~ynlB`R^_$fBW%?7hT@h(yIFaVYVZR!6yLTX)onBoJ!}(02lk zvgw3zig|{vBcGtfiB$syi2uXEEi9-8I{5e^#o-^)0-q6AX?a-bUk8mO_bU7W#rs%j zuY}*Xf;Xjg1~om_EzIj9`>pWM`UK)=P-p9W+$x`qIUjiBNb3_CzQ$H?PU{mI+}5gm zYo7jQot|baT0L%B!wr))IK`UvuQ*C$55Au^*HdUjwf?uNzF(~BGf7XHS}p&nT&v<|4bLPC9ygtUn`KBVzcOFtcWXLo z%5P22B$`=?ax3#!rHlF{(N+2V5pFrHl#fcf?^c0V%Gnd^`qRuzt0(cl5)amEH2BZY zf&WBs=p^bp=h1gOt$Xc1J*R-3nKrLmkEPjxLBS!xA*G=~A;Cd`IZJwY&YzZ)>+I+} z%f9d8vFsZ9bWud2>CYhl9=(~X@>TC1{z2TB7*Rw$z4O?`eYJB6N^r8Ul?-6*$f(wd z;Fh|&sc=nps1Dg!>pH9IcG5iNwE*+@@20NU3N64J79AR1_pq^j!r1KmS-CMO?k6Uc z&U^dzh&Sv**5*b;wiuJmUYsdie)tyj_#_a>`4#u2p9s*lhybRK~@K7y8m zYtnM8p;+_zwgw%e#YX%CErU{p)Y58>rt!?ZLTGta%Bx6`v9gx|E+Rp{#|^D?5`?Nj zzp+RmeaEdZ)Qef5_Sxi8ygYihbVX+RDlrDw6uN}>@Q__bgc10Lr#DO(wFLv!xJZs|gtSJ-c&EmD#>p4?0CpV$iJG}mMV0d^S7RHVv z4l#Cg{^s6;L&FBG8lJ#DjEfwcsB}vl#w8IhNY<~}0%%NrZp?u+DrtlSpWJM0E#ZG6 z{_jkQea9C^zxPh!+2x~VxBD?;#fphxsiC#o&cr6_(uj7!mo_Q$ChduHwN=7-HS+Fus}B92PqRjY1NC55Wx;gpgw}R~KPO8&7Y}>y z%J}odo>F!oqp(n8kp4SGYSZ-s@n>(m8}X-pI>kpOxcd-)Izo0FQQzIdYOpuQ)ct*n zIO+<@a<@LOS$ING`G&t~8M2kI+Vwk17Ez$CsuPop71AT*{&%Um+$INjuJc=RHtB<_ zzL`-COG|%B%{OMv$yl}4!_{f{h7`7K`*~Vfd)k{f?%9YZxDP{=bJzG@)TSs2x2J0n z*5ps}dA2l5)QzQ2P}EJ8A}Q+Tsku}Ny17}U>M8)ZOWsc?nC`UD&jT?Nl??3^n51Q)WeroW}($84eso-*38C;AZJV&tLpG}9J) z95K@dT^M<`1K%dq=cM|M;Euu6bNi9Y`T7~jWB98lUfYzMVhW+O=y!r2PL3EW5=c*mz4mvbfcJ4{++J|TlDzWZi;h#|C2ukgB+>4mJR>2I0oMkJ4Q`ZBIsIfVqbq$i6Xa6uR9i&i*` znS3GQvpcJPO5!}?uZ?bwGXCgX+;AqJ7Ot%xyDwJm&gOkG|JsF^M0@Xy_#SJO@87c> ze<;8IxT7C=B^Ki$xEFNMAF;=Jj$4fLI2OEO;lEOwp2q2~gaM=ph)V?#%c zc5WFxvTPkBi8wx8Xv{8}{}T0gDNZK{z?{X%fUP;8;^CVY^OE$|mD@LSlmbXL@7 ze---5*jGZnRgT^edl|*--8$R_@dj!mQ7iG?Xq02e%w`8Qm2b`{yC$^)mQ4kLQL3>q z39S|0x2f=gM?g3J%ZgV9bD7Y|?|>)Jj6x?@5eFl?F=@J)5oU!vKox|tJEV;vO-C_* z($-5=Yq-iAoiIH`zxQj)33J8cs8$@`TV%RcQ<0VuNf)XmKGfo$ zRZ2*As_+MVtdOnh@cU*syn5h*IX%33uq`e4J{bJ@5PNuNed%{<$!k@|~g?9pc)!Q4) zaPkfSZk7PGe5-tw->vAVF~1c(_&!Z5w=#cKx;}sA`y-0pw(zI29q(3wSIW;5>-r13 zNv-E&Yq$Zov5NY;!D|#-aS`>r2|hO!^$gItTFH^5gY}4BM)Ou3dVrfY$3q~Pk#^EP zd&Gl_dXA8R58Cop?24R5->~sPk?lRwx;*xR{g{89zW#d$&%!Ln zDW?i|Ztl`$^UlIkQyjAjJv;n<{q(=aRMr}M{;iic$PM4%96mP%zSpU*-Gw19W7k~BOZTkUh(y(Y? z8a*guDO(I@%qzvpnpeM}dJB3){nSz(ajeo;rPCQFI@1PoU86UYcN~@Tud-EV!7IX_cDAE7C7l96@FiaMP!Bxoua~TTBjFwl?uOQhGUHeG+OYK$&fBH z9;AEh&V`pZr6ij$AOE$_>NtUc!wN!7HeXo=O*hUfTXLD1nrn#1UaZIX5 zp+{i(d!nw;>)^Qj1Nfzv#Hs(zbo>D`-?@lT)icWEcUS=1Q69tcQj|MvbP{vC#J=DI zmF}ip+-HF@nEk$h(HZ&zli>%<{3f?bFWO0k-?D~_c2ePG@*pexq?1(mZAwp4s>JD%&`yZ>!b%Tz zkg8R(I*Gwxc)fmfdM{gFCF~70(&g(`;mOoc0e*JMVUG(+TydM;UaLhd`F5?Px-HGG ztP^>8)iQsnQYZ4ps_@5x7RCVCLb6L(?em4p){rujKXZOpo~y(?dJ%Y4V*b_jWIwQ+~Q@A=O3X z5$*Pt)^1GjmEbXAo}4N|<5ub&T%f02dG=)_~*02KmgD`VK7{`qyu2 zTUWoQ(}90K%FOuj-v_BY%waeeZ9{sO+6JDNF6_GEzOu9wxOrbSCJkdxEzHXh#kQLG zgi{@`5(EALZfpnJQG@eY1P9)9=-{nLZ%d<9)p?1qVR0ZO#o+sD5xp8r#BtB8H!Zps z`(9~Q%1Fx3&nm}0y(v_5+yHKT6yj0Rd@p~dV3zD8*@kgzO8ZACnlz_wVjfm>jL`mhi zMJ7JzNo5;$?PA-N46p>D?J7ZHTLvpr^!gtV?4kyAJfEyuzS}mNLkEa5#4=11lsS{f zuO=$QolBO$E{ck>j<7FRbj;S)93i!^DnpbPw^oalLWvmJ)e=QSS4}0FTfnLu?q>Re zeN~;|l{|)wRs5kJt<}ULV^$2V7Hgv7uHLW4&d-I(!H+ju}7hj)07-J`_Gn zR{QEQ)aw#<(1}<|FKxu|^K*7s#y;7^K6zbP6rjvw9e7rI-O;iLon6`edj$o+C-NEI zfKK>~`VwY1`=pf?IL->w;18|gM$)SyJx&hM(!&nAWH6^EU&%g!?|J#^oAVLfwMJR+ zHL!*o$^KW%X=n`>tE?)$_xb)xdW}6T=^L5T3%Y1Em(o|#^}KvG=6pn#@u3A@<0|m? zEpS`w@~!yzn{|4FhdDj$YAb%}j#}V?UlzVsw#RLAdO;7fr2nN#xn{o8Ngmei7h*~O z*bK+Y-M=f##c3Lsgzi-3;5$qDAI<4S`#5NDI!y!kg`8YrcP#WKS@J#NyYXGzW3Tst zJR&-{#(V6$wq_enDtBLFgXMWjHXEhvW$R4G*oy^RU-p@-J5-?L_wE32>tCN zc>73|QP_oaH}rMT7KbwEzWR3LgVPSK)$Xg;u4g6}9P~R(Z+ddM&d%&V92_}8j-2H4 z{x>*6S&E<8yX6P^6F$-%9bB9mJ+E2)y2BSl9^W;;?f7wi;{xZ7iHsWA z8Z|pr@jy35cTqjv&iXV0%Z=r*?d?S@CzKVPz`kYu+0^!K6V7f}a&&x$(yrObCB+-o zC1!b)y2Kr85|p_mj?IeSF(a&7LgCoD4Vxw>IcKa>hOSF&F*eDr_UOVyFB;oKse%VT zYyEVikb4zQ@~y%j@J%Wl@%Et70D}a=3w42zkAZeogP~#2wT$@7kupFIvM9@2SCYGqfj8epJ)D z$*1&zDt_ZSAxj#5Q4e_OHCYnNfY!Hs(|)oVYlA@N`8CwYR3UDFMy=$lf=j}#F&_rYb(fz-Z;bg1kztBjBV>;f(-&R{7+cR{3nq z`OtbUq)jz^jVs~uRU@>i2Dhy&ze@dovrbRiR80?UYF&Rwrv|@eh7-LcB^q4NYvETV ze{Y-9Q#%=pZwwPU2s7=f=a`yTW2@f-VT%LK_}IeZaW17ES&8d5tX-Fs)3vn2 z_@hfUoSop-KK1NJCeJ2biG`z?k8&<)Y>UKo?AW>tr_rOEHmn;}5a$^-V@JHQByLM) z5I?beIrJdO++N6ZZP8n_`wp)2Yo@#G6Qv7D^Ni)Dhcy2?1z*u(P}|Au3lZTTSeUA! zF*TJ5ClU^uz)=La&hqo&!#=;V=2W6gV!pH;Ety6X!eo>t~Eq@%PfqRN6fjBbT5}hZ8c|=%L-TztK1~%*r$ck zK!p?49{aRlm#IBKH(T^%0Y?v3?I_Z#R_Qf6PP8^F5>#L&hdW`h&jxlRwJzkKrY|T% z5Ax#$=V99u=UF%6e8ty*FB4^}@cYb5z{>#F39YBXe^b*VzDMFiMXy)kH?84D+`A?d z9KLD5lSo^#ZjA2D`WSn1ibdVkd}a7fbq@o@2dv7ut>MEgjXf2;UrqmuO4l>>c%r5E z1zlZ4?^n}5*5E1Fi-Vq_(PhLQ94t(rT=WzkD|)k<{t@dUBq9Yn!Uur2u$5}MU$aY- z_`INrDTtVue&)UK!c6bN+K$a;PT}X4m!CZJUeh+fOc+uGN4*56@59Grwvbi=v*yJT zCxzmS4EPBlbS%3GAMn2ETl?S@oLx0tD`oMg_!iT8om;FaNX!zB3Q2+70|#n@xg)r% zXeZO7-F1sFTQ_iBb4?C}KG4%#pGLUrrQE^P8Hm0VXOEsDeIdB+FYtU%wFm8*9szz8 zevg+ZR|$?)T$PI|T;XW98q)XS2^Ie)ICQ;r`ZDb-q3ab7jKSc6hF|uQe^v3o>tU51 zmO?P-688#sZ!!_RMmIxmAQhCe|n|Ta4={gnufFDueXj_zF zfh)LQvJwyJG&TJ#GahIM>-1%Si}V!_YP}?QEYizf`q5?{Tc-z)we*mkC88&&_;D$X zsvDInqR!~?dhFHoK}a8%sovWsBB5VS$q(vb3hr@a3*X)2Nap3Ghw5}lo02wE4yaq+ z6Z|+)M;=-B%C-{-@#Vs1-iUEWjSm#3wwre_Yv;mfgC!a4{K%$D>F%ARpscNNEH{4p^w8e9yW^FuW4BEY+c0?p+q!Yuw2eyAhRMVMvcoqD z&JV7_c@<8Ug9?ABaZ-g-i>UDXT8jv{u%HASmY3Rsr@;A*q6G;~me*cU&hzrwnDY@` z!tzq_HLe0D%S(mZT9#k;-==rT(<&^j5 zHa#01uAcTD{`W$l>uhz`nxIBdk5yXK_IopTQCm<6>Q%UPZSPp6zim!WwGtzen*JAl zRPo)3N!c#M`QivD_*T3n9tS3*MXj;!|Kc_pMHzc?FbIT`x+bKn@QM%x2 zs@4i-%a6E3+*4~Ow2zrf#y>T4}GwDUq7Wk4;jC7S&jmORULl_lf62g3;&65q-a-;*}({8ZUVss zw#|$ZI{00}u}olmL^7ZpC)qH|fUBRaQT`hl;3==1UG>6q1fZ2p`S&0e@V5d$BLHz@ z4eHPU`*+QAdPHd1y5PQac#Fg_n$FjG)8q}q;|5lo|4})_0!ELH9HyMm9Sz%3ux6!` z=`s6RX~1X4=jMvLU-1%Sn@8j|!j@L?Ke430Xq_G-EYhR(il8~{z;#3j>MPGNL6BgL_bWMwBU=o7 z!isx|efnOUwQjO+qVjNLnrG6*)f-<6>Kw2ucjJ_(k@uF(k9wm?58tBpz2YW&MYWD; z89h64!pi<0u4`P}BcpwXwHoBUVPVFL(L}MJK-6${6;27b@gs1A;BL^3s5v-!i?3&o zEcu9&YCbb3)qMLb`2<|#o5|jxeDG|c#+G~)+Wsq*j!Jy@wU#MD%UGvZ$fIs4pKKqs z{978Qi_kLG>C3czasp1CF|9rVuF|F2G>}2EjhsjVvB*R#fr8yQ7l{G_&?zhLRr=%} z`%1an^yq8XzB;&W?ewX+gPGh_**0sMJg9Wv9K~%+?N?qpxVIo~`i!h-_Iig&S>?y5 zwMRhNzJfBaZYB;?#}u0FRSwAs%AwbFZp)UHeL=m!oT~EHB3hS3+Uq>Q^x`!wJ>RvM z*QPAsg~%bSjwPZEA0YCQq#Ab?QF*Ad3a{Wg!MhQ#*{$IU$DGH4hcu^}{wCLJc=lIG zUk12H58K=d52PA!)bm|P^+ePYy}7E_LtP+(uXTITYZKa+4#UeHbdbAqg`V?3;e{pAxEy)o%n-NRy@(c?eny;*R4li^U ztt=N&n&67rFT6$N3T7#w0#vK;GVNZm>gjI-u5nSHA}kj*{V%vt!U7j#uL^$z|8phR z3BSs%yS&5-UfCK?%v>Z%5SELY{;@UO7^LB$c&3eF3gt(o;LA@Y@8(*d2%Z1cFBMsUVBT%2_=)LUo^t*ZavkunsqCrgi>apj;6+z0FLZ%=2M3s8!@f{PzQ>Xy z*~GV%QS4v*3)3b(!Q`fNpuQk-h}s}9KycMpP_4qLHY)r+_p^diZB+P8oDe`#rKTs{ ztio@Z;pl^?jRjt&ohVSf+}q~#R0AQ+DxP1eluJH775>;dy^tCe{)of#1x}oVp8gG7 z#GKwbD&!=e;b>YSH#04F7<;m|kEt^yB>$Hq`A@T@bzWJU_$~P8i5Dl~A3@m`co|OwJ}%8<A2km4r6zr(N(0^Zpgj)fPCTGp3aVB?BW zz1*T;OO99JvcA!7{wdKB_G|a=Kd{uruW{?BK}~E!Q-iuB53SdDRAkcFAe=vu+9!TU zNBg$^-TVi)3vzAWVM*TpRZ3p}4tf1TTKDNXt7CY_$F{*u9eW1%fipylYmluk!i0MVHDrs3$nR${EC)>5TOlT1Cm{chcW0vj}pEGd_Moi@B&4?W2cji zd0UruP=cM!g2HC4vqvw>DIC~mTqG8lS}D=ZI|s}iWxC0m`+4{EHT^+Y+VVxZKKe;Q zf5?b~qX0i!FWCjIuBV?ZJk+kV_@djsgsNIfme!m6>sehW{ZuGlI^ z7rX$!gRV)+;t=J-Qvc+hokj%8`KKIu_qJzU@}~0@6v5-@Scw0o)5C{2Llt8U8{CQp zU&#Y!75S9NCN)u3vm45qCTy8fkAKbkcSw&ho$N%r?q`FJ3HnmZcRfH!n=Uer><`At zKG+k2wP4wecKZ8x)Ak%h%Gxz?yx|HukSgzbj_O5$)5G=cbokBn#sBT4I6f! zHF;8Q_l6DqbM1!|95@rZdCa*ZD#cof3+kzask)toD zZiO~=V<1uNJTut2c(0-haUsG;8{!52n19Gj4TZg5vAEl_>fMTuA|b_}?)bvFry{cgI*4r`;z&<@ss1 zq3zvqVZ6=xT0yzv7vy-ZTR(MSV8e;|qo(&bKkK9UbIwlonU-i6v!GAvyY}j#p^N5a z&huH%4OCv)qU=E4L#WGGaJQ{i7tpDi@w9=}=tgmC zvf7aa-}uZ?n+gZ|gioCpwQ_g&S@FYG_!fEb8>aKlExT+Pz2?IBxnJ404Cu6}i?@B@ z3n^;`_304E|MQa4tWLM?X&;vC`glC)!Q&`14Q1L>oC9%CC}|iARs~UHr3i7LZANr% z?5blQ#daMN_UF6GB)eYIMy@*14c$Xf;H?(tLrj6s~J!>Lh0=_Ie-hmtJ`K z1e$|x8j3>;J7LU)>eguVbD&crpKVk#t+dWZv?3JLnsf)Aaq{!{qiYORCF92K zN}0GLdc@)h?V5F%v~F_d>9n08nc*X{qT1K-+b}6SGlZAg51th25!=IWq-UR`Az&(( zV2|pCp$3A-sH?Uw)0%o2+d!-En7zf;3*KHy8GG%h#_y)n=YqR4rssCpl{Uf z>0{E@8dcmNUl#KdO{?Jjig?#_2HxKj?^++n`}^Wu>tlHTK)h@H2JgTG{F+ST{i^z| zN*dlj67QNs;r(OruC))|KM{6IM8)s?WjV|0d=rbLk-n;Wz6qN%X>oAS;>nX22L~^n z9NV|wsF?nJ>kL?x^k?eYL4(&${4;6QfO+vTtj?(MNioXrBgc`Jh=5qEm6L=X_0sex z?g@+fx|>qgfYlv;-Sk!#-|yZc$I(slVf*4)Q2*$lo;`XFT0MK!#Lk1``YI3IvIG^V=L}%&AbORX zmwCIV^*u0FMPLIUiei(NEoiu~R(P*IVJ(IZnHU_}gOz22EwoNHv zUh9;zEf`ywo_;NkdzoJ8bmBzTHR1y3%|$+^YV=yPqo1Z81(|DSHd<)xIjB$Xexd$@ zoT6JX94s2{;MOuKc;TX=I=Kr%g8B`Q7}}~?7~7xasT{X&=3KORMG^H^DrY6iv7?>) zW_<&*r>aU+M>6ObHf6$G|DJ*Ci+^=%AMS%Vf`GY+(fh# zZ5}pt@PPaw^F{~s7(Kd2z-ZLS5yf>uNzj3~Xi@0E>CC4cP9JNed?`D1&WbC4O5Sdw zXggY}1#ZuREe)C=k$y&1TWjN}QT6?hbix_uCDfm-MO5fC-5EO%**y!4QC_;XW`iQzgajnVd5O{ zZbN-AD>|yXKYM}rG;Pqp`9lWH84$B|f?{Itj0u42G$3G%D2e;?m-QzkarO}A6xil& z)}#NhZ*u>?>YR`=9>&+IHV#&sRU4;T-{dDMJ15B{ax4N(V1?~}wRiHczv!Q+>kQyU z|3ppym-;97|C)-^oy>+IKboeJ%exBL8=? z$x{B|8p^+8Repjf-<*Gj$bUzE(~>_)`weGA z*EmMJUO7Zygg|<<*new;P_F;Q2!WcS<^Oj_2<6J(j}WSa{euxgxn6CAP-XFdV}wwy z{M877av+jk2605LE&>7_Y)P|SV18_Yr1O;m@{`l27AV|T(P?m;+ ztOGxIwazf9Li+h#DW{6jg+YYTW#KNTJ<;3!#tT>6>PBdeP(P#t@9fifK45@)qR z#4i||>k8f0j`MbO_W*}XVus2R8a8cQ=Sn@hRyIZVSWXG^W^I0r8u6?0jq=_)Wz_=+ zAZPWJx$Fu2#VQ8-#*OW1p5C~zpDnIYVJ})?;Kxdo_m%$;2IkCs*0CIc!Ez6aqtph} zSowvj-@_t~&gZv6hg7?}By9SSkG6NXC%1?$c;@z!(d!C_eWgUIB^Be$u=b!;ElKs5 z312xa`O@t`s*}yn&r1;hBxGp2@uSjacN15>EO_q$HlsJmo-Yah&7qlJvm`a^QyqXSW#bQ#ZpeT;M0^QACqY+lM zqtzpj*!MfuuHCs~Z839up`>`*w&LP#gnc|8DZhYLgMGwo;gxpvbHEaXtAkz-w<+7( zN_iB)+9*H7KWTZ~k=aCIZ&uUzC$0EM*5~k!X@#54vhK?3>XQOfkW10<7?B0of=;-_ zng>n}f+S*sK|dfLKhCm?t+Qhp|9VAA%y!>%TMG%DnbzrN`^~@18nV{YERe zA+NKyPm53Z_B@`RWT)g7uobKG?#}*Y%l98xzJi@X0P2=4#cQ?_@6^&Qmbz9tEcL42 zDodwNqV(-((R548p+(a_H1{B}5=rw%EK`W>*)?>Z$=`l;DKg^Hmp9Tx*U|c(C8w98 zjE)n`iQg>2-=>vay(@JZH|>A#o*a~RqNoaiz2u=>-AJT{d*=9!C& z)DE+)?=a_>s6|uW_D*{#Yu>IguA#Zn(;&q>W$5(&9+BN9;NGIK1<~z$CS`jMn=`fd zSS1-k%n^6=gM1hCsijzYi-?bSwzDWGWz-1kQf5bqQU(m2HlU_b*cOyRtr`KoI$HD9 zQQQ|Sr?WGx z@sZ-1o8$!3PFa<5y7z_-#z%We3;DA#d z#91riuod+z)ZFY@ox9A+I(9WC=EL>tgEubWZ>`uGx?##smrVKR@1_zzw^ew`iRk0* zL<6*{SgC_vQCID7w1}O$Y-hmbqGI*VOHMrgcC@XbRqx=QJ%f9>-!HZ%{(iV4oY*rMgd{NR|Zkme|PK1%Mb zmJHf4rjk=a_|33Z=+lihOJT9l3%yLGpaGvx#d!Mpc~UXY5OEcSWRFB4EnA?Fk=Y^5 zH`G80>Wo4CB)ma?l3k2dTtwSrJy&A6XU{4H`ac&xy;K0|-a?unHH{y3|6q%j^IK=n zvRtL0yFQ@c&)11}2P?jVf9>KwYd^=7&kwTl3q)80AR=ih7l@;n%XbV zs{I_-2Q1CuAv0D6ZJ6}!aYc9V^Aw_CTg6Se9WZpli;v(LyybRkOVZ|F3b;e06vc%L z$s((TU1{gj$|*51Z}(WYhJ7M?_KfV)&~5DQyu?JOR=)hhdbNkm?i-Pv(RQ(0+`jw{ zNzqLvu8v&O5QbRd;&^#WMvxn6d z?e`tp?;^FI9c3lD)}er?D*$IDEsZmsXX@k3C8dCx(6K=Guf<3M@AqNO zq`PF((F(BHrenGqjXHE`$|1iXa!|mqTV*D_)iaBVG$Ua1ERV`z_euk?@=;)wkHAW} z2RaC=V3Y&a$|06k&wMM4%t(h&&sC=J<|p~c>2X`qTe!D~AF;DC3!6>9|gzcBb*6JtI)}$8Yxn9tU(T~p^OP<7c7s!Fc%oknlZqPiE+lo0Y>jY$GWg6Z_KxU~BCh_kf8#Fd$ zV`k(}vlYHfegU!5MqLgfh5itb@6m3QW@=>`&P1ANl!j`>zeKH?Kd)B!YE>)#C2B=! zC0o?}Yt&l(jvBA32;-OZuhL4tgO9q{N5{m7;Sfi=+O=yVbcqO7fcnD8uB+-dtvftu z+O%#nCOY`Etks}apWgQQ`CikLoB5s2W2|Qo-?)%l22fBxCD|7SX_zr9?<5eFo-(}#j@Mt!Kb;f6^&*^hjE zrQNWT{mOnstQ2zWWWD7$*&Q=Z%;vzK66GCzj4lrG0X-lpPLhp{cUxyCoh7c!r*b{Y z&y^xW=-gg$kb<9sK_?Di)(dM+uBUvUyv~|4CuYwik3LP>XC~C^yn9aU%AqS(vtE6- z&VT3hjj!h19@{NZDH|WXx|wosXvj$A9mE{kv1LzwI{%I`rEk}g(Zh9P;s?$hJZD#j zu|vmqUA0+pKX=@#(<*m=_S=|V%ox{VRIiXoqWoyZ5BiNb=hagRMtiB_vSm5T`YKGU z-uZf~cY+xmj5ukMre=EFY(X&&H1r&uzGmpS{;%yVdNXm4XGUCVnybC92d~SH80sIH zSm^)t**%|5_mF3pei_)Samf1lW0%CT(;H5A>c2c^^p^MzGo~j8_UYH$cWi)LXrC@h z!@BnMSTE&X=q#VTS&Ksk3|g>v27721l@TM)bz;+;d3A5>VT zFToVk*m2Gw;UWh!OdVa!4otN7_X>_^HMTTAAHi@v~tJ;s|ZkdgSNV1GY1VSb z4&~PAQ_3wIm(=py>P`Os8;Vw}=sI;=cGmRi**S4jyRK-E@h+?P0y4dmk@1f5>(&>P zU*F9bw~w_sb&5If+oybU>Xh=IeIE>7Hj3GgE{u#?GrCzF7<41El{d2^BeR3r#H@nd zp{r7}ZW25<|D z59P4j*$vW?ZIzeUVC4kcJET`rw>~h}xU`rnhW?OMqxl?QvSkpJWlQ_bZ4k|9!$w`_ z9@p8FORt`}IPc(&6I|iG+1aKme4jF&ycO6btsRjKk;yUF3&F?|nsoW`zR<9xMKnrg`>l;z6FE`22&5xZZzu5ekv5j?z z(7>=>1N!$4`;Lz`y`!zf08>hxMZv|a`UstCbTymt~27$SNhMNwtqW$Zz3yMf2zmk z)iw!XZx8-h|!RXCvD$M9stT_%#aUj|Wke*ducY%0r2PzsC0Z=b*9 z!>7gq+dpPC5x=kfGj@O*SXlMq*a$Az9&1S2L)$zdb8WBO`6uJ`?B}$PKyU6%%7itA2!k7J8k%+umSNy zrVY#M(7t1bmBSXdi}q;Uqh)qvdSKXyHOVPOMJdT^KuuAFt8S%`lCI{~Y3t@^uM-n) zv`x+X4pkGuz^BGL?NAZ830wvMVVsVBXiuU%M0T8T!_$7xr}@!_rEY zjr&*?nm%EhJ%?Z~Y=;ovrc<3qElymqXFzxN7hHmdEe#v)G`rjG-HXpdAJI)@uDT_9 zx>d71d39V#C#W*xfGMsQIvVEYIB-UEGe4**ZIG9t9Jv3+*M?vJzS~mIix&}`J->CA z7sDe8oI1`4%UHYWN7-2ZXJXur6xMY6Yxa!~6|Y%ST=sf+TmKSwTi&-x(_x9q+vCS6 z=b@)$>@d2gZvv}IlO=HvH|%WNe=17|+HcP9H2zB|lD+csl#lZAOyB=A5#l7(x`xpB zxQ!N?lk9#{^z`H6#ZQ`kk$p@hyr1dS%gik=Pd9J9;<(;45p8`28X-coHT8>srV(f` zadB%P^B>~9Ub(->*JsiFQ%AmNE5c@M=c%DckOu7 z3r~PsZTm{xh%R~#b|RTX;khL|rL*AoeEjst`y)+H_|E8MgZdXInbxt~?Cd|YvsIqx zcK>ZH1Z!%p1?E=I)~HN2f`%eO40A`aM#nR~$*evrcE z;56yTBnqC>o&5RdvO(%fIV?W&sv+eGVVALNsI40Al(xb{@-Av2Rk7G#Q64he4Xm;9 z;|4fWSh#YgL^%xV;A@fNtb8rJRQOu(WsCH=oFeZhOtxeIVCcu7Z`|_mVfr6`OnLBN z${&BQm>=#e{NyKJtfYJRmUVRI~habPF|`6e(=EugkD+v@yGnN zk3WXIXdWGrLme-`b)+@*=B0e2d6yhv-U7^@nlV$8n0du#tX7s3$EbPaPs=YclQ|#i zClw&yw=~c4({$lZ9&6geOAEHKwjEj9ZOYDV#D~gsuA0)6#ABFQG(tFu5};h%2Gr&=DQ95 zkyrV?gyueKS=62wf2c*lN{psZ&J(Vk)ET54QS)#mO3+{Tg=eDPQIN}XX-XO zyE}Jr&6?ue$+>g8%nZV`6P|XGP)q=YM7!{JOkeR*5@Tg9zHjE)x}kcKI7#cME}Z>` zWxlC=+)=r9K`)y!a1`d7kem>fZ>XbdgOz=EsXL@v^|v{=RPH6BIi#B(t?0IE=jx!c zOt=f-JVX^J&>rN63|X7J?At*e!@pUYymrWtH7T)sR{42&E!Z*|ue_-4&^@uyvG~7* ze~aH+BWeo(|0H?Ch*fEm3!^qBCv6zfZ_ZF2F}GjT#$^Es`I&KX87so#G8ZOxpRg3Q zma)RA=ri@*5!WD2v34b^PE3z<@e2YWpNf`s4Z*e=O`S-)^1|2>B=HmC!EB3L#2nf zOoDqD*rD}k&n8*UFqNrVjfFi#J~PDo(3%WZeazb#X^W-Z)!zos>fL?v`ZJRYzS$Ym zd*+be6#JZ|fxZ(rBqnT}=o`2!dzIUg?~c69>g|nj^4Y9>`umaZmbk52)v@6F((|`6 z+qTWTb-wibf{v@GN9};Fe*mqkm+BA&kQqByH)kW;!9K$F)bb=JxrdXoC|B?P^m=Ym zr%t+lpg9y4e=+hv7t#$-w7(QL@*Y71KOTf>|wejQT;aJ6ay#yO-MQBaM~Ug z-?n~mBCAH6QQgcUJe1hc6fFBSz};f@?9<7s`iworYQ4;o_m20=_HQ?8cY=vuT8O;J76;6jo$(r7gk8~0FSLHA##Vjt!# z@R@VyP`=-yoIbtm8u#kgH>`1!u-AF(b*4LfdyOCYp7;aVK1VLTb;LLO<+!QXzc;o| zYCyom-tpMKSAK`aS=+dmtEZUQLrVcOglbf~s7j4SO0>-Xzf`OLJN1@3{NG^5)W2et zI6nrvH!o73!G>Y=m`EJG$Lo12C3Dy#clKzGQsSxS9hKx{-p2HwWVWT_Q-%7^dg&Ye z@4CCf@<7KFT?Yo)dfhhbv}K91rzcCBqu97BHgi~-CvuLc_zq=?eyT@4Bh4deS7R?V z#b!0VBX5(eBzM&Fr%Fe*CE4^J`|7^C-4FK zH=g#wm$OoV!9njwUu+pWs~`1ruinqJ$#jZxRovCLM!EFOnWgKFnN9&EV9Oc^t;dw z;5OYaL1x>~4^5Xo)cyDh{-MZ<7xgw$0=!6QE3%IhE{Ll&SVbevD|EWeQaNt;5_n}dF9(|Y)H-z|N?FPN(BUi^7P19q}iD-Vgr~ zmVC{a=Rmhd4)z;Z@Jo_>ql4R==zniseN19FBjp;4Hn<6Ejn0&C_0lW*D~?nwuaG7x z75F$pIr#J`_+yh+R5x zNo3TB$fX0pkkD0`gOZX4Wv&WE3suIN|>DEa+>25o9a_Zudmx@0gUGh@VW;%T{sa!AOVW~yR*&q65XiU-SH_+tl_@Iwe!eb+1bAK$2|HcGO~;eJf01G+iCP0bM(H z=;&*l-*k3VYHq6T)r_V+UE6hR@7Qg?pqVl8lSqPJ)}5B345x&oLeoYa(48jp2J(Ly z^7n~Fc0e(rF>J@@U+SpL#4j`$#95UcuAI}YWub<7Xg|faG#lBn-VQ8}msraYoY7+mTEs;7U-JB!RGWFhs7%6=)KVwT_} z5++XhBg!7us$#U-@>TSRvPab;kYtldx>s@Tza+^fQw@?_vD9=y%C1-_VK1I4 z6W(qPYEQO{>E4yOZN3*~y5Q=U7H+I>jJ!K0`#31iQ9PudFjIifM5`wlPnobhNA{h| zs#2$>En6lfH_RD3CQI?iY+>8GN%L>^bQuxj*f)h*?=vL;eWwofU(83*4%%8j?gt9Z z$el1OD{zRXcVImwV0?V&R?j+KY2F@wt5EXG%0wwj%Bqw;oWE1s^aL1B;G(oas0Z{G zBOT-BWmHc5cI3C^zfXLEPrk~PufG;@qvW#A6&wCWb11pp=cWV=8y1u@7n4RvTV<}l zlr|-I!kq5i=S&EWj6{eBQ~srFV(lwdK3`9y*PthqP0#8H<)Tzx5wFUM&=bl<>iy8d zHO0G4I6_sAL*N+Yk(1<%C-Efl+CkWdCo1}<5Lf%C7hd;pE)V(ts-{NJn}A*oeH zn(F#8&Wcz=qiVWSd9lh^q|w|RHFtYf1JZiVvtx=CdV8;49_^d8cAJdh#r#yIRyoxg z5?tD^yUcLs26_l|pXehPhh1R{SdGQ3p#KL4kS;_yf9K9)8k5z0>z>I6JH$HQ?*~5U z|4hDf>{DX`%ES7HrMw!(9cw%vS097Usm8(WYU~tronz6)5a4fJiJ#TTIqMR7*3jd0 zS}Cls8r!&1MVeugFc-||Q!7f?xPxKrsw&K?ELTm*5RK^LY+S;|l~iEHaW%}6v>Hy> z`nIk<&JJHF+kdMtbz-Zulv)qDX~<%&jP20N$Ra~OvsjgeEQ?iX7$PMY#U29aV~iw* zA?S%1?+xRm1;(?YhoTg&JiYOfVVq{aRm{bsPW>vwy}sb38_HAjS6o=61H?I6ew-!bj_r&)RpC@Xj6OJknh=b91_uhQ14!b^-@6>FQJx`k_{$*Wl!6}sn*^rneC5&QgqCW#Uw@kD)Bo>)`_Z(yMA zM?PJT{)d}OMu}aSftY717=v*dvh;!qfdRf7);Vs*X7Iv_TxQ7kv|^}2_-v8pE^BIm z+@+bD?mjDqr;(wo@42^L>y};8ZFqH5{n7bJ<%WkJZZu3=`gBf*?Sb_@CHv?@a z0eQ^)RfysNhp8hV71Cu562Rub@}%2TV2ToE`3Ca<-3HygK&@YZf;n<`aB^P=4XOFnMYxREOPkY@Ptb^g2NNcuhz3kdYMN(?r8@pB?2oTAk zwWI~Qy0Vktn5sF&ena*uP2C32?7c4g3_}NgfOj3*ewY3}R&?RH@H0=|#FjV9Qijp! zXTv-;w{Ptk78bbNZ&qT`luiReW;Di;wYAQ4jq29k+owxw+mREy^h)#?I4*=`FgCF5JLE{(M}bRj(qZzuU?;Rk`g){C{Lc{0NU*pCKAB zl#A>Sbq-^P)lBw>QfmjMFj7iK`%wSCzG&sjoeeGGs3W=@utivjP3sA~* z@R=eCp|$qR(EHxjo;hZ=SG8vuYySV%o^?`lR$qJ84nOq@C_dBU``gzUk|NvYGO3{Ret8kUikL_bn5DFXo$v!rY+OH7a^!kO%ZAknV) z@x;>zK$7uyFjA#UNm2ryBak*jngsZGkvdeGhQAX5%a(HRy}iIP6D zF%3&qU63n7nj)YXc$y%k0aFT|(vcKVj8|@gUT*~TH4T& zjxRHSqlZ=M&QjI%*7d0RB^tSj{!~#W@xY9^GEa4EYMyj0N2uV>6yP8(QvKE1=YZ05 zKr^stFblLN7XxFv_zt4MJG_KwzLzz zP`RN}B>pCVwgCJ!^TjL|BzZ)kx%I2ouM4=44SxOia*YILvqTTZa}wUBLAHkDc?zDx zfISUR^IvEItVYnkimhn;8vg0A5eM4zGUHfX(J?(UVAZFV^bV`d>ae=39;?q9NQtZ= zYlM}H#?m2X%bH*n*Pb~@<5^SIj5U`IOGT^&Ybm+1R?HDj2q)&u+F+NJ30s|#uxitm zxw3Xx`f4w^u?`63>BQVwXV!&zu&&ILb;Ht*H}k=6$M#Y(^TmRdKkF{N%K}&r7RZ8F zFzd-eq$@0xg-P$RUaU9k!@^l#){pgP1K2<|hz({#*iaV1BH1vh0~^jpNF7;}^aqP( zBUua^#bViLHinIrIZ1*$g(5&62vXTsE8Ku{mrmo5$u$9xR_NU<=tIwwM*LC2T2M z#+I`cY$aR83Z?I)u52|cVr$r1$&(ec61I-5XB*f?wuxmK+og}N z0>6{(l0Ig;*-LB>Zlu}A_Dd7k0roOG$PP)@*kN{r9c9PZadv{8WUsJO>@<6oy~fV4 z*I6lhLt4YmN=Mi^b{?ma9L0jt1stV%QF_SUmb}>|$%nne-es5B74{x`pMAhSWLMco z>|@E7eZsCux7c;5jD5;JWB+2GvoF|}>?_HSea-%jP?B%hx9kS{j@`tG6}QoL*#q{F{m34%$LuHRGxjt41zXmBW52T}><{)Qd&+$-$fz*RHl*(OrTkeXxYu$Kz-a(qoJMvE4opF;X@k#bfzsK8BCwaXeni z;p2FMgmY*3c%HKCh}B1iBINfdehzQ4C`+t2Th-_B>B^F7bppLu4^oH=Kn z!y1$?Fx!{{3s=+FbXYKZmd#|}V6&8m**9U`{5dv+r@s$ zcC(-1t*f`$UiJ>#$Nrn`XYVT0VAF1(GM&B04zQmq&$5H;7wi!GB|FT1rOaeU*ss}9 z_P#QM9b>;?$MNj;1p6I3$$pQ$nty;zgg>$~>`&}0`#^b(om0NSK4j?URB=KbK=K&tX2HR9@pi1MNcskGEnfxB*3Er9C%e(NeDZ_YI-i>GR5D#N3 zYL4+7%|2p1udy0?b6}*y<;#IsF zZ!V7EWBE8fo-a?e46o;tcmtozr|_wK8lTQ*@Mrl<{tZ5hf0NJV&+$3@ zTYN5mp3mdo=JWY?_yYa{U&z197xC}$#n|z^gulp_@|XBB{xV-i7(2L3wV$T#sf_?vt){~_Ok9q3#6kNGyfo$ugp@tyo9d>8*I-_3u< z_wcu|oBAETkN-E{&)?;Z{5^hv|C}Ghd!>i?FZp5qD}IFknjhuw^JDxs{5bzDKf!;; zPx9aMQ~VG7H2)(%!~ew3@(=hq{vkilKjIho#~e0G_$B^lewlyDukgR{tNb&5jsKNj z=b!T%{BQgw|AOD*f9JRP9p1#7F@;tI6I=+P36n4j3-*H8gdG<3qj6W$K{!Q>aEVyq z#y5*R!Yh0tUL=S_ktC9ZUj#%@q=;0}QKX4ZB3)#_zR*3Qv$$7u5nmHsMK_TpLL!Xa zC99OxB1hzkJdrO7M0e3c+=nk`+^>9J^um1Jjb}+7thxEHS3d!B`XupyC=^BbHgSn4 z6=kAaJScjLheRLou;?rLiT>gdF+e;j28zeTATd}B5s!-eJk zQ_APc4KY$wh)OX^REcUaT8zP0TgHj;;%QMMCWu;5Cnkz#M7@|K8pLEVMNAdb#B?FQ zbvsjhL(CH26tl&1VvhJ0zES!-z6kzpF<*Q~ED$f?Tc+O?i^TWDVthAiiFi>g6RJ{Vke%$+VPxmgfbF) zi3`Op@l&x|{7mc-Z!3+s_t~Mmg|Ec@MA@Xgfj24Mk?*~Uz2Y6QPyDyoFWwc6;yrOd z{9M@}4vJrhL*kcs=3!OlEAx~Wlm&QC;#=ad_!aixUBv3Z5oN#lwKyu?7sv2~cdj_D zyp8pg--;8;-^B04N%4DeO8h~b7Jn3H#Gk}j<$3XevP_&4AByw1zxj)DRoR7U*YvQlsy7*k&5PuUl#TVk1_`A3*?qEM$v!-aO#x$-8P18)8S+i(Xe22oWMQPER zL+hY9wHVE%#bW179Nx?KYCbJqOVARvBrRF<;~kcumZGI<9kn#Ala{V!XqnnQT4(KE zt&8?Gt*h2e%hEzxSj*OO@cM3^mai3P-L)RteOgcLe(Y3uKr7UWv|_D9E7i)BMapU1 zj(u18lX4RKNFG!^(0VI>z+LC>vB&U?au$0R`e+YpeYJjCf9(-%fcB_1PR;Z*@KQE$l#49n*jd(=` z#(5Ei4`)$2S)o!&KPw!LgfsYMMm|N9PgXdKj^UELQghLSic$4-wRloHrmnX7Xj_>7aK(k>FI_F%Jq1~x)?)cM!ZP=A>+KeA=dI7(}PrV`-82GqbCs#+a7FJi>y$0 zy^+E>MjMBX(v{_i-jx;gG9L89sG^<~&I|0l+ouhlQT(tWxiGbEQNGSE$}gfcit-Ce z*+YFy54H3p=z(6raF!v^u+cUp6eCntLVRd?k@+D*zz?+(DCLZ*DChHu% z#X>o`dagNC0mL?BRc=(cJCz)5Kz$SbIzJrF;$^jCWL)%}u<4OD%~fjXS$Ar-P+2Ke z07s(&rAEO^jVw#`EW<{}4M&toZ|1D9-Ueu9L+?w6-8r#yi z5Q&jvj^5A0VdFg4s9&y;mOj9v;UYb%$TU!In1L+~lNB;Tha;n0t|7Htqn^2XNy4E> ziH%(KQXusl(_?K4muD0rFVFs1du7iGN5(c?!$M^djmk5!>`qBTYYW66bam69HpPsD z&DXUvYz#)>h&JXK#mzGW7rCnC>BSA{S4Uhba!iBU>W*>c$TxHXW202DP$`XVp|X5K zxl8piF;r??GD;0KE;FiImZuGF)sZrzLgj`g>a7ahFj}gtEPC*lbtIH$9^6((@{7!a z+YT$mA#Jd{fSLxk8C3F(tn!V5cQ*=@Z`4~KU{DdG8FO+?k5hBmA8&0Q>*H19s_UyK zjhm!*&H|D~sH~vC^!S+i3VntVF42Vnwo#3OBKs4qi5O8S-%uF_{U=(YW`*=iFW82h zjqxn3>oE8QreSTgsC$-eSi9>&sI0r8e|iOxBDDa9?_xgasj>RNkXK}?XlY!`Y>WXx zzfj=)rf*Fyar+QMDbQrZasu0dKvWQ$CvW#nUfuVs} z-R+}Vo60t-op6hajp(5Q(k2XnC8nyD&KD{R85IgeN?{DnS^8id&NT#(We6lP0+s8f z3YFxUs;ON})h(%F7^gmk)D3YL8_g9m_$5ZFB}VQgM(!n4cZ{jIw(55EKnojDiz6+S zZ?10Jr9wuMpHeY8NQL#`Agn7Tnn$mAD3X83I4?Jf znwM=FLp8ULX>A34qzPx+#$t8pb)#wq4SZPd^^vP} zi6NJ)V)Hmd?&I3ZInVIVD=>{~Gl+#t*|_m~7tW?4W`)YL?Bm-PKPzm^4Z=A_IkF>i z&{GZRanV<@qn;j9UtL{WQ&C$rZj>(P?vY4FopVB_nl>G*yV1hk^$Tz~uf$Z-qN_+! zZ}?DliKszq>9uajkCH~GHOiw)0g;R*$)h9A4F!)(ZS~t0h`roYtB=XG6%*?wHPqKl z99wOwZIgYDQM$;Kyk!;@k(MrjaO85OPhBtrHAc3a?z}oOd&)wOM0V_el|QJ6Iu zaw;`+Byy$GuZZY%Mi&Shl?&^t9@ZNJvnHdH@P+iFr|>JgC%RRY6DWQsxs#kn!Z)o0E)H?l7?DqWVZ zO>WhkGDD*JO*%BiD4gEbI4_T${AJCN1ASY~$w#cV15rVdHksy9liLhK`9@axMnUo; z^)<4LG+sf8X{z2|r?xhZb?RMnC_|VK($toT5av)uYZZ`eF_$W~Pj5{`A6N7nY+O`w zP19SVV#Z_0x7?5~UV*3EAzh;pyt`>e8y)H%vdw6BVZj{A$lSP~g!H=(%%Ms;P)d0P zT@jZ!5#H}KI{gfy=%{EFLW@t4^f3}FZH27~7Ikms78?*Fl*}foBGMeKK_uT=K@x3cMhb#> z1dSP*3}8&s04+361Bh;&u;I*FLD~bPRaAr8j5!;isFY+Bh;>wpltxi!1P_xjyqi%| zBc-TUFiTd&(PoZ@)9BV|8qP!&h*h7K=}4|c0T~IjN+>HLeN1ih5WrO3Fct?Pi)htn zm4qT~X4Pk9;OR+)WKxdmuV|6tNXi)JTbrSzwOvah;zWQ*>&clK5{yEP^_vWwM7Pe| zTCy#cBCuAsZ=d2e?MgWs^SPE-q1JMQC`V&r*Ak#OBABdjuFOGJWd^L=3O`y zPs`9GhHNS74sEAa(!U18s!yc|MReSVQv#-0&Y5u3OHIk>zAXimlO>8xmu(<1p(T8d zl)k8y-ug6&Q2T6hq@L@^{R()dD8c-HPOp#=&^wuX}K#n$ZFq}rUOv5Nt zq*7uUPQ`S|n;sFDqs_!i#+?2o$rMCXRnEmIZX`Xu>gAE9!HP+1vzJp*XUOpSe2h>; z!=hU!W#H@SbeEC!xwihC2ji7~&mGbiKX7hL_KJ;rapTq}yL*oI*tjthDkyfg@#rSo zcw|#rTx^|DU0>H#5;LKi(Iz~K>xx6e)H)o{<#4AAU>;pJxt@;WrjY-nanq##Bs^fO zm4MzXl>utUQ386(Rkd{!EHXs`GEE6=da4pyDP0}OlqsA{o5Ja-6V%hEfRqCvDs%1R z3H6i*0hxycMjjICdFV*yLE&T`6i&~Bpq>W>q&y^&l0)Ah`Qn&kMQteD*eI%njwjhi zBV_|eQIf`qE{92zD<@GI3CJ=^V3bipy^K1NWu$Pjj1*2UBSA`*xK=77MIkv%8dp17 z&qD&r10a$Ippge5%0q^uJY+Z{4+)JtWI#O+Ad*9tzF>$)Uv!U%BP|c$sLLm-fNnx? zPWMPS(#;!=bW?&O-R$8=_fp|3W4%60Um45FG9Gwk>1(j?H`3GZxo~czmv5w}uWy6j zJtF6c5jg|sa-P^K=ZP(HmPnT~!s&8GxQLtqBXUMSL(UT?Q7_6WqCSQP-$ohq=YTjj z>ZCvU#d$F+8AxKw*H`?*`DNYh6RYdT)m7>0J*sZPgbIQYHmRY`me4d|TovX{T0L!4O@*98MeC@l zZmPVn+Mk(RJ*i<_ox~j?*zk+FD=CCF>N06Ewt73|`P0}*5{VyUFj|AS#!SW?S@i^Y18igx+rqjl7<%Fugl!v)SgpYz+`5Kp zT=gjwu8NU!JqRv0#4voL=_KNXM$+S=ksO36aXC$r62tvEDPmUGc=8_B*XF=4fQQyIRV3d~DBCLAd_o1vV{r1!xO0@+6jd<+4K@k$6C-ilDC92G>1dM|X z74_3a6;_i(*<@TnsJZkWjECQ)qGnt@U3d_Q)^u>BM?>(;Gfk|Xg!o1rV>Hp%(-F+* zN&59FjH#3!PJb+l^IS{ygozE)k%C@g{h>Xc0@Lt-o`>fn_4RdACr`A|kwj-DD79Hf zQn(a7SAUx#oLiEQK>!|@E6o+qYbx!K8#r0`=U8d>(S{q6T$wkH#YV`It`#*@ za`0&U#gPPyqb`8#tRgGTuw_a{>>ML@4nk$M!J+7}b#+fyRMt&_QnZAo@*||a#+9u< z{>{qP7cy})^3@kAab6sSajLSqrfzC$BKj*8S=st?1RRaR8P5~4^_N`0*Y!2qSaZ!b zp0sBhYscBfTMyaBf_}E~bTB*H5KK0W6j|BEv+2^3e4~1Ks6eOTRBVSC11dKnsn`G`t!y#pMc!E>PJm5)F_Rz3s$E1as_z#IE` zIbV$ebzrB8ihU~apowZ8Xo1=jw3pfov=DnsRJB-zeM9Uf83|gcR$_ZXmHI63H`MQd zepf}QuwUa<(AU)0K-a13K{sHxg{tmTQ7ZL4?5$9-vjXM@DK!;#Ifr`BZkSg{P zRD+IXlR>Alsi4zg8C1nyfVrUa7)ppY`8R^T!QKGf%wVGi@9d+rctigX=wWsk^jGY+ z3d5WCSAnnLJ$V&x$u}t~-jMfz#^YUe6>q6O4BC(5>xy`5{CUuCbJQJgcJIb6z~y2& z=xVVBbS>WKRq@X52GDH+If`>ySB1-WJJBK@yiFU#2E0P054JwdgLS(NcpsM;rBDz; z;9cSG;>}-0DV%Mb^Iy<;X5^fGgcRi7M;DBO{hj;-{J7SiFqA0b%Z?Byj(opQSOcb9 z*it&rXgOa^=bo1HKhSwndnJtOm;&i8itTGwuJNjNtzzo5r`J@4;TR9sfx`=LG$_wDz{fJX-o z8a!m^6T_Yy{`C=0Ra910j~+Ar>6*G{>L)c!o;q#%jAy@rcd@Cg%5$*%J|AxZzlb;9 zU&D8kHlY_|Kawmvc7gg}0hN`O_Z_74sHteEg&%*qy1rJ)nov>yw6b=>(-WSCtnJDi zgv-Hxpi=B|8H7C)RoGxR4R5u}pG*l_fH$SBNKp`HhjH9x9QPQ`&lQzc~Qs>GE>?(sjSnR}A!Qn_tLl`qkW+@t07F2R@OCsq&Ngpm6 z2HNkDzMumi=?6N9eDO8`FzvMGR0i|{G2VVfQ8lM$Jo?rlQz1AO3;Gt{YTgNY&{+;n zLNt1+>1>kZ@75N9qu8J;qAR}udYtbA{{}yWFnc7%?gZ((M+^l248IQkdVyY!T^{p6 z8+jFcD@h(v*xB$^&W#X5Aag}=V1L9{I_zZ-3X5GW({XKeFQL5)h!FWT-$qSu%CqKc zM0E^9I$!mZp<91#&zi4FzRqc0*nGWtFPss&`BwAH)>v&(B!tdufA){Vwe@L_o78bpPg)^4KQ5 zy=}u#N{u>grsLhd?a!NGnza4z%bdIX(du7S!@EM=YQEWgL-Lyc&*f`dUrLFzqxp0s zCz1i`*gT(n=j+Fa@0X8l)04Vu=RT0<$o>D49=9W`jSR z{$G6!xtnXJE6e|MEy8T2S$@I#VDoPE3m{btZORFs!*M|K{O~Eo6&Qe$x&!_|Z6Grc zMk%KSdIshM%86eE?z8}Ai0b*~Blpi>mgdtBY?Y%V_71x-)OxT}*n}C9v`HlQ`LDr# zpLN*vvH`na-CR{k!6RtcsAUG^o6|4;}T&5xi#a&GFRxoHf|O*_)uG!t{v z%h;2gsNRdcz+ucmi!cYhPbsH)ULTt0jiq_sc$&#gq?z0dn#nzjncN2DWz540Vf()y zAFiy&-o9F82WDgS%0BGJTZVmst1u_~1+d^~g7%Hoa z%9=`L?MP+KrLy**vi8KT>5)n?b|}B9^r1QLqcoEpNF{!fN<5uP{4ACDIV$lB>|M+* z7qTxfms~1$Tq?_Wkf$ljsr0L;^c$%3JE`>hF_&DVyo+6cyOm$c-RXgU@b_Ov`(ktm zWVQ)|3bqU1)-RF{?9&9~ANtoWY{cK+hJSbbVeo7F?}_tr;~(M6J{`f4V-;GUBQ)HH z0wyRy9Ob=GK#})C`HH;H>5luH7h%poYD-^gfqv8iPf-h0Q45SFZK)w``37mr9J(i& zhkKH5DXZz;;|Fx_@iyIE?8Du~P30)H&v9y>Yt%m1NsDetEmA#l-mfNL#(zakl(T#_ zhj-#(^JzeM)N!(9)X7q>)G1Q0)LBxm)OpI$#oW_%wI*W)_=E~=KsICIsW!;q2F-I zZ<3k}cfAEgICM<5{EKdA@;~pAlK(f(mE7w7arpG)uX2^i|8FiUxz&9+Y>(u-U1{?F z)%8#QpWV=8tS(?YXgfZnVI?I%D=DeCFUZB6jvTqWVkM;zt1EK!$fePvAFZT3LMthc z(n`u>w30H6M)xOabg!ZjrkX~W$+$;ciMu4ZlJX5&NqLc0QdZ(FYd^Sh{CSnepEWf8 zY@_k#Cm4S|RenlqDEnv)<(IUEa)Q=S&e9sn2egKAN3NkDL?IlPYbdZEf;ALa4Z-S( znkq*sSn>EatYH+%H56Fxz#0mybzlue9V|yMSm?m&iCQaHPhe@|5NuLRlB*}Mwt>|X zSlPhp2~xu932bX%e1q+bX2#X!a`i-AC09?>*W~Jnx=pU0s6UgdC+Z$K2C94I7^t3> zW1xCQu9&Fj<%)@VS+1Cte7AzteC)J1XfI7 z9|9vM>_1?w1U4YBRstIixW2KceN z?fE5X&nKikf0k=+>SuE8O}$Q<^f_tL9jQr31)2n#B~p_-q)A@Vq-@fp?xaaQNRx_4 zlO85bdYm+AC~49p(xjQBNwY|kULsA}PMY*n(xi7ulinju`j|B7@1#lbq)A;#le&>6 zg-MgLNt5zOlk(O1-eLcetMdNyuGag%anrp2nmg_N=iMCdzv2dY|1WN#cg_FGE%W~S z+y(Ey=GJ)M_@~_t??2=AdjGLI=>5OCW8VMSo%UY9*qT9OE29}Y!?(dgSg*{+=o*Dl zaU$+erO`AyuAi^M8pRqo6Gq1Mpz?0jg!|PuL4SyQK9h0<n zdMYkC#(@^A4=E0eV?z}S&Eh%D;yJEr^I&cSS4cb;kWUi?&FBTjuXBn9OGk;I$t)c- zlidsY<>vx}*)ww@i0UpH_;2hS#<iUA@0FU)WY(+tU2(J^<-2}SaHM? zr)XT?UIvx(Ax5=gR4YccVyM$~tZK-oNoHKj9Cx*|e5NGm4oy%!W2l}nR8KpuXnpT$ z`I#!7dZEtqurl&Jdb0F#>vN=k(88*;A%zx}&vrCg5zwf$oz&V+YV9b!wP9E3s1gfH zGSEJ8zqnuN0J|}Tz_99~bl{mT7_X5o=4u3e`Rs0A3UQ0VcX4%bIb1$hs;f)(`at(47y83{QaE*1f-M9jL}|J#Z|I?GHK>PI4=WPKlLncI-C8 zEr?wTw3^N~f*xq+_Ov^beqZLak8w2iCb?5Y&&Qs}@k;E?_%jjbX7Oj-QTTh@p7=8f zY0|la?#}KULcQF*6P&HiJy6H);l^>aJi2GVO(Z&_HLMY0u6wzA5&oN7+*+WW(j}N8 z&b=>!g=3?G*OXNq&iCBz?>E-o!D%|OL*#c^HZ z3P6kF`o;~3n`O9>aU%(hk85b*Nx$}Idg%FaOW;=C<#xyIj@y9$R{W)Fj634pa<_{+ z*`5=3&b!59lP>OZ+zpT7vBe*f&f~`4@5uxWd&0!&NwnM}-2l%*xMBELd1^tY(fJ(E zHSOH8c4yMBmD7sa$7ww~=#lOXqB}f0aNO%Tn9w!iJjW8cdQRhi!E+&@tJfi&=c?zH zSLoR5^QI=W`FXqOC*C~cSQzp2wx9^d2+=3zymt`(V_Vz^pn7uiyb(9E1dN27by7OL_M{iT){L5$LljPH#i;k7P zbYB+A(8Jfiohxm3Hq?SqD2L_fWVk?Nuxp)oQz6B2lL_j>o(*bA{&>3_>D+zvP&{Zrjt z+#ldtbk74`>|O!7-o3@W%e~)y*nP=;0{^r4U&^@RzV2?)X`ChF zN?cZ)3;(RRB>dCyKN`0*u18#d97{om;wWilTut1RxY=9d*F`79l-xo z-1&@~aaZuE=bIkpiGuTZf}YNvf#h;Pb3DB~y>T4wIqq5O8SR+}H^Vd6v&gfYIGa5? zJ^O%;dk*1$(R0Rg(QEQt^W65D4A<4`^mg?o;2&{mEvT=zz+3F?>z(Bt;vES(%R3(b z2K?vy!rmL+CEk@duJmr8|5opAZ=?5!_oVln_pr_3iNO1wH6H2720e0rYCddEc#g5$}t4WSo!p;h(DiGdpK? zj_)EVp*%Wz;^$?0;tS*Z#P`8*Q2dDadK|~b*T>JqabEo5_!aT%>Axj@SN#6?!|^BL z&%)mgVxh8W8K{CSFlEI zta4`@_51P9%s3ix{%{K#fYs=7tTivBb>>=H2c8Bx2mfV(!a$!up?{744gU`RUiu&O z9}5WoY5xWPRsSvci-1E9MQBjK7f21{1-if$;wa+}3XBMh#q-F;fti7MpeF(=0_#Dy z1a^V$4;%(Pk(m=X8@Lp>9%#zU30m-X8UL(cTbC3}m*<(m-~#-61WSYcaaz zCO9QHo6c8*$`DI~tAnS48-v?|dx8gWeiUctal8U|Go^D1ONmPHqy&L-QhKHIP8pc8 zHf4Cq=#+^mGl1r%EJ|69hv_UD?Qf{Z3d4gbKn~dbbO=Z!j8*2uEB9f$GvHTIv< zSlXbpE@@plp6+-dO?154@fOZRnj_7ZmI{=YR+!cYAqJ(5O&fuKtE*3|Pg{{TGi@H| zV*FR6t?#reZA;p&wEby^>3<^aY$r?FrL^m5O`UdivUGBFO7E0}e^#d+ok~0P?=-Yi zWv3drU7eE}V0rZ2#Mb^6BiZRvZ`52PPWKL!4k^qW|!jmq$3 z1T#8k?E(3e4+vjfapT#Z!lHYnyrHm^Eh)P|_oN&Eg$971m2whzS_-~k zo^m$j66lqbo1oAI(DA7@y!m)Tgyj=<-XYbJ>W~N)hm}+(p*Miwg|7>z`In{Q>9(Ta z%X8%0eEZJ+RKN696nvFUNkuBn%}xDMQ&TPwdL`ADiZ|EP7n);JZl@fkaQH+<%2`4i zfQ};EojdzePNwVzPi^X!atig3>CJ#IeYXf-Ls3%JBHV2px5`@MO-<-UXkHseN(zn+;oK7N(j$@Q?dv_hA}d(V%{69P4Q6Nb$HIdf_V69ObXT9d=SV6 zo-pU8_=2b#V#Fc6(S+UyIzs3rph7~38|(?BnpXxb!Qntcqy;YqAcN*+k%p(pyA0mJ zz!n)!q+v{jbk!ij?WT5Az&jaOtjEAR3Rpi-z(d@b5@{Ro{sMT-%|Kg&k|(qcctfFH z_eBZD0;%ww7aXB*K9l_>xI1`2;doyEo8Y|Q9-!Mm6NB5}`#jLd;70Ji4b%`^4Rk-y zoZwRUYCygq+7ec)e-rEyoDDRTxdL7Mmw>9+XBf}>13k=K!Cw9osK*+h5q{{rdKO6W zqvo(XU5RVL0HEW1TQD^Unc~UGw!r)#+7i!b#`#wyj|cB{=1S}wI18j9MpV!b^f;@+ zRp=PX65yW%SO9fnzvZ6&&Hn1|f_H5=pD4CM&S(W#_T7qW99luUg7lnt`+)N|&9YqKm#FkE2>IfjNBLd?kORJ1Kq^CT0Y%wRvqMdrk{2aI zAK0NgcarBOLrd|NhLVgC1?WK2CjYQxDYJ*bBTWOZ(O;h2OZuYJc<%w|PE)sJC0R=T zi#z-MLzB@H*cVOx{QZ+4aC0zh|9;HGeNH6xz{`j<{wcy=unvr}cX)9z| zfKsjXS4tkj?WEjpqrRJy79xg>yOMZ$;9W?9J}|r~lziIYy^?$^X%2XQxwAhhFR8ag zO`DRsBuO3lOH*S~YElo06eUT%%fT7qCF!+X($p_WBssxba%X?yt)wW4nl>d~O>z@z zOuUfTB$1*dQf_=Y^7Y76_;i#dD)BN0mq*pN5`Xc%N#o0vnWUvgDqXNm6YPac+-PP|RY0}{Iu zYD_Lq^r0-SrhbXN649Ty+O#1lDiIn1v?+-tnt&{j%kqTV5}~AvsLX29E0~+Zm%W8> zjR|KYLitGlgaY2q1jt1oaMETf^GcvJLNCJiNWx~sn1GbGCP_^-JiRkR%aODW+70B9DTEAo(_8J)G0v?Sfst>${@k_z;A>}#Jm(`)q42YLCuWQ;AUmlNZ6;NY*&-jrNAzwLL;_405 z2%{FzV$`xre4a$8c`E!RZ&QLV9(@6*F~Jd^hPFiLcwE^fLdu8YrCj!*EL|m!jfQ69 z{!=1o_Ceoe;%&m6sg%oTX!Z`DZ1d63>^CUJK%{cmhbsjeh?MtBo|=fbt9{URH4(jH zsSkYtsL{8;hc;L7?JD1Fife|1m~Rf^no-}IzF86>y(_+X2=|AkZt?ZLDnk9@$NGjz zgm5E#&~|*^LWv(lx%~k#`uGM>xW@QGUoWIG7}7}aWfIzu;Pgq27z}Bcd{QF@H@y;n z+gAvbi7yu`KG_#Cnl^dQ_#6`93soc`e8)oZZIpTNzR2UK7jof!kuOG9DK65B-zE`Tjv5{FE|Ca4%tnA#BgQEmA-$vCg_4K#4tQmo z4{hq^+vy!iXp?WVS84>b7;=WkA zv?+GE7jjXdKe5!O)nB8&dRwBd-bPAg)A~Rn&Xju0s3cX&g&>klxHP)D19uWe5c*nkF{4> z^Jz_+uo5dZVjA)qp!+s8VwH9Tcw-=!9FMH|806O3Qz{Ws4pO)=$k*eMHP@QDd6#;E z5}`&5JW|^+0()muIE>2PDV_uh*XXVB2%zQ2_l8G~aNkENuDIJ0A!Uq#(idepDG}b} zRJ>P+hmj%fG9g^8yy*D-za%4SZ$;FrP(1P4*`OtIKE2VqLvOiAo9;I+E zxo^Y;38lL)$EA{Q$bHV!L_ELyWZZJ%WxJ1f9mLz~ZZx5XB9HgoyW_?ZTIJsAK1(Rh zy}^Bs&_VY~^1WvHg=eIDfaGZ#-1GHPnRDH<-1|_fKY+J0ZlFZwz3$E0EkbebwQi|R z2h9Gs!Z;6j4vX3}1o@fb>Vf`f`7rJ>>D6k> ze)l!cVhZi*;>fWy)$+Pp*Z*E z*qstt-gK`eIj@FXmd9?CJk)oQy9dxK+J@LU?ktJS>td(H%6y+Q`(s%w+K4;Mdt(m? zlpbhR?7mp&IMBM-ov~;c?m*nlu^8{c^T)1@JxnMrc6n?sq;lK5&TWbs(h| z%Ouh^xGuz|0)2w?mT}obKS6yLx~55l7;|K))CbIa9W%J>q2B>7=(8_qJOUd^>S2*?}@%*k#myLM$xcsrRiMQ9~#vOvp?R}Rm2Ko%N*QLbB8leO+ zH)48AWO*m%a!emWZ^fL8k*%s}8)8oCr839G9Es@yo>$x8TpiOWk$JCkDbq`z8?!aW z4PLzEw=o-HF$y5P>X?ornix8VEFO3V=WhR_NZ zV#Z6JhOsPWB+xtNy)g;cX&}+M7^f4`0D39LdR>GhBzS?Ab(6> znJ-t7@(HJGY1O>WdDz*R&`Zw!PDUu*xyuQ?;;Q8x=N4BIp;ONFE+3(gbA?MvNQKlE z<6Q)a-gnM(N{L%g(j+I!!Y%N1;TyIRz3;R*rNk}f+zw40^b+iK4suG}kY(x9VKJdN zXJH4a5vt`rXI_jR?x3@aQ?`O?&UL1Cm?4q1pVNn{3TE)g?WQap8xdJ*Xz z?I7FjMfe_YTqfjq?&$!10dKE!8{TA*=zZtL4&{X6oU0v&2<1ALI-s%aWrSORy@HA| zc(y|4>zp$j+X(rc6FUSYLODiv$N?%cg`5W+=LkhP_d2#nWX^Q%aBL;?E9V;y)B(Ki z&NYsD3YXzr=D22{g^tsNwmauIh5^ksS2$}NLnO~!=&W@Nlt^3Wta7Xb>SKBxZ6Q&S z_JMPNW1_(u=#=^`%hJ;^kI-ai*wKSfj5E`L)(*O48GS6;BoSnA zFe(p-i?pcWQBr=~lo2&BItwTj^Gy(Ct0p3WWN%1fKZhE zeAE&`8TM0A*9~+u>I~2{)4S1Cj%AXkePG`cwSv$N`?jcAKnu;A9CM>84KyQqE}>42 ziH`Av{EpF43nh}*j;J-bigbaTmq!f(@}U)$MwLpWt&3g|H54e-T;S;8C^UShL=7P{ zIl3kaZH^c*(Uo?T4>5K`54EGsflfyEx8Ef6L3FA87En2)vDNOE2-4UP1(_0B83mcD z<=O{POYFyhT;}c3eyPtuo1)!z)J=718=`IY8$e0s?T%VU0ni+6gI(CskHE`w42v2I zUE2@&9kI`o2y$t(&yfhy-EG@|`p$#zPCLS}vv?$uYLn8PXUedzwM$9PLyYD20Y1w^|I{>@g`f(+13J;nL?J0 zwo}B5u^zFZZb;?0wb3?OB5j9tw{5mWBF(zhx|4iE)(y64QsGE8Ot)E~GBE2Z2vdwx9Xn?uY-h)aN1&vrO z<1&jmCTfM$9E8h=T422@5mKHlYoyLGy=cv}p9QKiy{aho(~_sTt#0dOgd1*(vNl>r z5X!Lbw(c{~R_hxQY3r;Ttn(!@_qMLIZvYx*9%<`gJ4C!b*7^43l4p9)mTrZfBE68c z!CGLT@kYv%ts|{2@P@+ornL|G##sAWu1SPeD7M@J8fN;L^{icvj>AnutX(a#-G*x? zt!Wln^I_(ftoyC94`#|WcZ)?L4fk-CMrdpo=xLe-r3cEeCs&p-WR0BKUdt4VtZxod?rA|=0%h347TE)G zkaDJFBB2lLev2%#4Cl7U@h`_T*|yMG=wn^D#VsEuaTJj<3vyjA7~s_#Zqp?SQeH}TGyM_OgO9kO^WIpzVxt1|bsY=Lw$#8%5Z z^CgK)Z(C-XhroBaX}e{md7wn_T_Rfnytgg$%~D^a?=0$r%S{t34MOV1YSVV}JhLqG zYO&Qk)4T$xKWbiMHWAuxt~4`3%gsZrV+~$^t8AJ6sBfuNmj1iuPpt{oF!2tXvrOA4 zTsL#NwLhT=<|Hwf(A#F0EHmP&Fwl@kyj7+q^Atjbrt4;kVIFC^ggyNTC$^f-ng#h* znNFB@0KINnZaQW`F9X_cI%uji&|cE#*AZif=@{`QnBEYw^NNBsd-m;WXH+6+&u|(#}>SC!m2=}%+&)kK0syfp$jd-indebgK zh3Z%n#SmN75vBpet5OGHd4aPI_@ZBvsi#Dy?c%ycN^PUqs?9cKB3xI~+u9UO=Gzs# z8j~BX>IPbGvLW0S)7z%Cnv~EMlzF*UPiTT^5tpfKL5=2W=ZIHjnqgW(=xuF}hB70@ za&4P=E1~V$M(TrG%nxX*%_u$l)cmQr!8}9ynh$FWU^zj?=%&p!FC*TQ+7xk_&@ior z+6ZOQDrK1wL)C_w8-RXfdRwW*mjQvqR;5ZCV(^BUWX*qMR+RzfzQn6i%C&1iZMv&A%<-D0W|r*NkuLw_NT zYA!L$XZ z0~*3*imu?Hcj8N-vezv)E$16evaaZ-e6`6zs2g8OIv~R>fHbheW_la@sANz4K2ll9 zFB-fhROauSRWV;XL%b?{$#O2xQpD{nWJ@hKt>UFZ>eO;mA->EiwPv~betf@G$`4}( zzVN!7e9!SDZ8o7Q?!wnP;cH33y%uD^Ymu*?Pm~B{apS3rMB9Z;N)o6W?s*^s=?hyO zw-L7=dc<`uQ^^CbD=hX%w4JBX>P?=h8&A;Eh&O>d715UY#W`ig_h(16xmuOF-cB08dIne-7Jch%s1JQekhKW8- zv@6jBqTPsojp!VrjA$j%K}2T|okg^P=wn3diB2Q>9irbN`V`R`qLYb^B|4Glc%nmz zP9ZvgXaUg@q7M?COLQL5M~E&Y`Y6#Si9Sqp7}35&?q8_4NqAw8rI?-o| z<`69+I)rG5Xf{zl(ff$j63rlbKhcgv^N3axts**_Xn&&ph}IE(f@lTNbfTk(K2LNU z(RiYrh{h1@LexR@A)7;<8D4mI#iKY^Lj_7|8EhFkC`c0yGzB7Tb zw~094BRYoYGNRulx(F1uPYAzE^czI6P>7$8XerT2M4dzh(HDtMCn{^9C<};=1XUGX zVo}7IK=c`+^N9{8`fZ|56YWJbNHmS8sz^IimDoY6+nG8^cBW1%Y1q+YQ(gF?{bhWk z9uH#G1ngPv42ugr)MD)08K@3ZE7kGp9JL-hk>@a*x`4UWCF%<7BYi{NrtZeh6Ls}fwXEkvoG_a-h0L6jCXAZMJkmfiOOl3ZS!#py&6L)XSy%NMhQ0o} z1uA|>TA<=nqy;K|NLrxchol86zD8Q0;%lS@Dt<^>pu+04l#FV*A>R+e8%GNjA&*Sj zsFCNru@?$QjOBC`(mIwrhh;Z9ZdFWlgzYIhZZM9p4oBx}jpG{q$YH-u^6|t=_lNx| zI>&A(I!YTiGW{)jIn-swd5v*|Wi08BYc(BB{f%>J@eA()s&kFwXd`?f%7-%JnuS;r zsS|-@nHkztw*kl0mB>%$?Sy3-9UaB)I~m7MG)OdssGDdiQ9IFSqESR+i6#+^AsQg+ zB1dK` zA7CFQ*JBhlI*#-7K7#q^GU2fqp+d|tRduRJjMc9_vj!4%|z7ZR5UnO7J4tl$`Z3o*|w8U43 zlwT~ryUo5jr1dL8GTS^w&ZEBkoq2ryU138t1AdNw$j|eS_yzv4{MtNx z`@r`i|Ab%Sf999@r~C^43%|-gli#6-ujzk?t3p;WSRYHXb+%>M@@zeA#kStI{dBj2uH`A?RI*=H(O zuv`Ck>?Hd=JH`IMPP0F?3x8eatShPuL~)XLgx=%C4}#z*59# z*x&zGcAb6BZm_?xn|RN%36>-Nj<+nESTpwft5{~{TyPDuc{8_gE4OhwkK)nX!8>p# zkKryJ%iTPVd$^bTU>hKTC-Nko%>8)lG00PRD(}eCV8bVaXYzY^XMQj5!oSA5@@}|m z3Gpz`<~cl<=fR#pFWw#ZH23kI{C?mqOMDrBnJ?$B@D=>~d?joVt>&-tHT*TcmapUM`43lyZBG}Zt|4o#9;A+m@S?YbHumAT=BeE zr4>SV>Xpy`Z7r;~@11{p3#b1NZ5(Oke6(}hR(_zRo!hoDER+04+j+)6Z0Tp)wDm%? z_M-of+Iu4IRsa9epa1i{`OAIzKiHH1P3`ud>81bfKKh?(yS6>FZRhCbS*>6)b7zbYxhb!%(x2Rj$1u1slY4~cPYOhT!#6GydP54 zzu~Uoyr$u5u>rFma9|rr@(;?HH9yY(i20;Mzu?DcW+_y&>S7Za?r!W!`1|n>VjkSr zJ_s}6Ogn6X5>0C2>b5GR)os*WwPLY^scyaiCHxsX0sJZN1iXj$LyGm%m(3#IYofoxpkG7r zCdv@h7I!b=ad97{AjAO7CkKnZiizJ5X3+^V=0EY@;;cznL^@{AXZZ=7H4CfAz)boB z{yW7aa4R7)F{?huPvT4!c5x48*dOxWxB;-1HeY#ESpa)!gOw$4PeP|(Qij8V&T_0h ze;@8CX#QH{^8>h1WbL*}*$g)Z_Jg)6V_|D;yD}bqVHa}Ut?WV0@4(e#b-WQ)hz=+} zNB)PDV|Y92I4tfw2ir8~mBp}4a|QO>uED(m`!v^;70M0e7IM1{w?=7#dkrgG(aKtk z94=*x>Q>##Hr1#h76D`S0>qp;uenDRcX_zh7`szcG!PpMy5M<{1RktkEnqQ?wX zJ`(j}vT{Mp5YH-?#6q!9xvYh?Fj;>89J*Dlj#kI0W7Tn3V|!Yi04soX>O@!ptcMN2 z26d`B9jk87!cNXC^_#Hk_APa;`aCQpeNSDi{ufr^UW6^cmtdcEg}MsX-Ck4It3QAR zq1V-o>L&F~b+h_I^+)Pf^~YGJ+pg|V-%@v~KT&t7KgFuu&(uAzJow*gBkaZaW$KupM^-R)>DCo>Kn^n}g@okJOLVPt;55pRqc4Mg5ChSWoP$>%|^mg{+7bvl3Ry%2+w94EAOZVgFoz_6Qrm9%TdBW3YHMgbjlwpb_jT zHj-7aO6;^7%f_*%Sq=8#)v`MD)n`~eo5UK}WHyCOgKfGQ>{&LG&BFe+Ij~SS54-uk z!(L$D#S@&R>?QUxTh3l#E7BiKUR0oy{m z*iYGR_A|BzRuSJ}``CV1`)y?Jv7fVpX!T-IB1$m|3_z>nliXsmY`Q7*T zxqrOR^Rc`$&&-@NXU>^9bLPwpwVGDS;qUQ{nP>KAZ`;l47oi<9mnd|NvfGTMV+S3 zP-m$Y>Kt{RYNgsB&mEBD9;%n>qxx}5^dO{q4AMMFO;OX-3?zD<@<5I`I9rp);^VwT z5v~%_GWtI)q5pr#=R(r?e_1v$_8;e* z;GE<%b53zibIx$ia#}8D_Y27JB8=o478uF>vh5$yiHtvmzWu_rXePWI?sF7}oyIL= zLFZ&OvvJb~YlcnkqIr;g5Wn}ZkK%6!v=Qil?EQ2aoJ-GsklO_8#GVY>HDs9R?Bp+2WqC@HmZu_V;T^%|j`KA5JIs^P z@iIDIPRA=qJo1p-GtpBQc)h&~z0@MaPDZ|zox}uMo3!}C z?>dZh{x_JSERdRLwqL_2iof1JguQPw6!(sWzLY2s+T{&@2dFY&gxTITy;OK=?+kgF zIBzTdhs5E$4;2nUbVFqWDzy22! zH|QKB6wZBtkoOHDN8UH-m;8!+XPFr2Ia$8|e-1AmickLy_I~7-4LT%V?`Y@?Y)IIP ze_uqrPoPh+J;n zX+917%|V~bU=`l8g@?!pMN(z^`e>%7R?s0FQ54`w!kgK?(6x>T3#G56IVfC~AVuDm zgr^Dm_I6PlXmti>0W<&8-*LCjT#UZ|#Opw>xdboyeG6Xl`*v76?!-SQ<%HbRFXmL&2Y$bbyL}$Pi#Ysa zQ8$s%1+%Yc^nfzl{WJ}%G|&@%1}Qefz9aRvfv2`m4|(Sh!=tS9*y%yPCkJ#3ddWJB zab+~$@i5v7Ft&}s&V#djBgU=3C`O$DM2eM%)%{W`%R7bH;$ldF9`*a!`#0R;#KxGz zW{5~eLHr{A8=?Q0kN^ArZo}M{j8XZR8NUfS*5KbVs)4!-@nXaiijkI?!MJ)DBU~Ec zeOkXTqg`);bTFDR<7*PL_0DbgppvWyw%tblC))1r|418dr z91>=sE)ccG2r!M+|+n|Pdioehn!29#P0OWm!o4BQWEz>hKW ze-1ZOy@L7uhnUeH!o0ne>cI><0rToSobFuDx(Sx6AHf#&EM_2wFoO}Zm%}>qV^~H$ zBe!b8KcI^}gq8c1Sl|8&)}`O!e8|~_)s7RGdv$UKxhAfSo660@9BUiSWqtr_(ob-I z&ixHe@PC>62E4OB;~wCi;GW}l!Nxnu_3-#SIo30*Sg-gN?_u7vyub6_;{BU9&bRQB z_#64#_;>Q31YK6tZ z2H^(b7U4GGox%sOggb=^z8u)({i=E=d;??l|*ebqP`~&fi#4n5AhnL0~aff(7 zVv=M@iX={~SgeNk&sNDdCHF{vA$d;nN69CWy^<~*#JNGbMY>ITr}R(Kze(Scekk27 zJtRFPZIkv&$E9;JzDzFD%Pg|ZvaPaj%I=nZU-qc%DcLV&&&mEQ`-iMsHY}T#r^#=W z--YIDrmfX3!`Z0UYj4q#bD3YzzNGz^_7m-1?P2XH zZJV}N$I(f28l3~Do>u7^bSrVr=@#8K-JQBeb} zmHI_Efp~*{i+&qUFnvJ3UH_#17y9S*f7bt9|CYW(KcJtW&n%Tkt&h41=Zx-%`aEhP z%5C5oiVfc~{NC`Q;kdzLUDL;= zJ*I;(!kC1ZH8^4OmYAQ#d=S$aGicVEvvCK{cg^23zhHg|=VHEV{=~c&r(otI^OU0RyeZ2XeJ_ll2y>%lahFetgdQCeC{7vyNG3ZOJ&(@jBZ&+l{tcai-&a zwjbDjWP8>2j_ri)oUJoKmXM!No^X4@T?r2+xDtMx@C%&E_=kj-6aJa-Uc#q1mGNZ4 z`GoF-;e_c#cA_*#|$s`F*OZ-~W(@FnMI+}DQ=|WO}(s&OyZI0hM{^0n#<1NR> zjy;Y;j%G)zqsK9tY)`%+`JUvTC;v0~{p6j=UnCz*o=A45@KfX|`6=Zo8&d91c_ifz zDKDq|Gv&RM9Vwru97$uT%2V~Jmei!w?9|fKy3`e^8&hviy*>4=)Q3`k znEFQQ`>8uq_op6D?MUrUlcs6YY-uHF)oDx8R;O)B`&!yJ(wBK*VFc<9Zoxy z)|S?rHkvk*9-VGYPf4#&zb^ge^xM<#N`EljmHy-OpQpc={(kzN^h4>*8Ip`e89&Z= zC*yP`H&dD!mzkWIlUbTsmw8?0`phkvw`YDg^P$YgGJl%+pP4UY{x$RU%=a>PX70;8 zp4pPwl{uO@n`O+hWv$M7H0$ZCUu8X?^Cs>lg-Ia$}Y^V%3hqk zI{W(UuV;Td`+;m%_LJGa%>G^WpR-@hemnc4>^<3svrlJV$R5a^$ad%CpHX|68UlAD~Hom-Myle;W;ZSD=Zx8}NX zpUizB_odv|azDuZEcZa}iQMzKUAaTK7xVae=DftbqC98blDySD(FL}Gw1UEd>VoA3cNhGu;MIb21)f4dp|a3W7+;uNm{V9%ct_!Vg|5OU z3x8GkeBoaTUoCv6@T0=Lg(HRTB3Y5X$Wml4$}B1@sw}#p=+>g|6g^PnDtfZ$7e&t% z?Jhc0)Lhh7)K@fKG+)dsmKPg~t;MOudBv5**A?Gd{GH+lie1G|7C&G7YVkY89~bW} zK2m(T_(JhO@x@|KiK-;J#8#44l2`KGl3$m+Sn|)3PfGTdbd`8YWu>;#l+tCT-zj~x zbYE#(nY3(0+3jV&D?3uwQJ!CZd-)sX@0af^|Dyb8`I+(y<^AR3<#QFBij<1-ikm8a zR`HjL0~J%1{K}fjJ1T!w`Pa&Qm7XeHRYlcpRrgoDRdv3quWF*o;}km8&KRfNndK~T z);gCuS35U4w>WQee%pDk^C9P>&L^EecRuUk^YkpeutD4`{{ITZcn%8UI zt@)^CSIz#KV>M@MIs)%d&2%kQE2vf08ftB|MYYwn%WBuxez*1~weQrn*ACWB)_Up$ zb(wW#b&KoP)P1e)J9Q7$Jzn=;b#K?5uNT&9>SO8?>ND$0>g(!P)?Z(LTm5(IAFh9* z{y*w}UH^Rj$Mvm?^oy1)+O%lfq9+&qbx})$wjsVDv!S-(n+;DlywR|`;cP=^!%)Ly zgJ-c~v1ze&aq{A<#f6J27dI?kv3ULBZHw<&{Nmy_7k{vL_u>Ofv`aQGxns$POZF@| zxTJf@+)~NXgr%8F%a<-$x@zgymu_GB!O~Bc$(PkHyL;Iy%i5MlFTZa2lgod%ym|TX zb(-r^ueY$bc8 zer3waij}vld}!qpD}TN6#LAvkx>bu;UBBv%RX<+!n^iBYI=))7`nJ^%uKvmDKdpXu z^^VntR=2M1TRpwn+bC{SH<}wAjRlR>jVl|!-gsBz_ZlB-e5UdDjjuNTyYXP-xyHdY z+%@rQs@5!Cvti9`Yqqa>YRzxgytQWEn)7SM)_B(n*J{^hudQEu!`i#oKD73!wSQRq zueHr<+t&826RlIPGp=*2t66vBx+m7Xv+nbCE$gZEN$U&N*R5Z(e#`pX*Wa`Lk@Y`a z|NQ#Dt^as^%li2ZybW<1GB?z0Shr#8hHq_nV8c%~{At5`8xC(cz2U-!{te?B<~DLR zN;YaX#%@gAn7grZ+PY&u;;VuH@w8Bm+-1KY#ke&pIc?oHL*8-MjZB%dxZ`PYspr zO7(gZY*BKR$z&=mHd!q4)S5+$JRZ;F1m&LdR#%&K4=FOxe{}tjjGCZ4u-O zU)^Gmz(w^gT(@N4&daW=%f6KF_!uH{sLSwPMgXcEvqc%EYM2JHuz*e;{>}Gj9^O2f zS&ysczJ;aNZ{aE)4fTxdrP;%WDR=DQw87K3={wW*yQbcXTNA zsimc*HQ5<;b$NMtg=sZh;n0yIN7_e6Yic;&p5w=lcL`!@s03G&92bZtWhCih;?35S zltisdhm84ly+tb!2(q)YQxffxvA({(FuJOdGgO*_;Z3A{uNHnsO-;wO;P5y@hlc?} zoi;x|KOxAnXQ!pv?W;H3@$kbBS5c3#?!MCro6Y0g`|i8%!EYl%SidAotJNmt<~p&; z{g|-MnGzKhp^eFaU|&x+Pu+)H8nL2WeKIFDJf?0d{|2>kUO-cTqp<>Kq4Rxx zJ3pZ&rN-8g#I+oA^elmH0)2+TghwTXQ6)0Wjrh(Fgqb$u_aY)p%7QukS_L=Bug@?r zJ0i;9o(q=Y&%+DkxqM7yxskYkS-3lZ$JNo|^5rpOHk^`^TrO%eGiEOo$H1`#;C>%% zt-d+Ol#q}h9OxhDnNuhfmYT2KUqgF9{fPRJp`>Kd#w}|%ZQ8WCu_z{H>9_B?>#lEn zbKBbTylNOoe@Xp=!(W@@2OQ*dP#OIusltR@4%7ZA2u9l5PJ74NL$v@jOjnm)@8Z7M;^l^G z0ydNostH7ftr6cN!^(*7!Tk53KE)c|L@!+*_#%16E{2TV&|44nYLd$7dL?P7;laVd z=;+K$s&A03hX;czE394_#li8r>-^Q!uNv^<=H}YFhXHkt9fRlK!GoWlXB)E1pgR<2 zEL{Pm+vPHK4_$y<Tn_Cvfu(fGJ=k;@B~l z$YM!OwqO-BAQM%{CkfRi=xG3J)hZ`hrltSKw17oAgWtK7L8TwcHz~=*I(FG zJ_(87JO$;WB0qZI5;{r)Fc_SJbRm4AZXFWHC)>^)I&{eG&dSOf8=956kVE6H$jvJ+ zFVEqVkzs0hdU|?f#3vU_IoL>I|haNVs5ma%N2=4^GFySC~0gQ zMWxiOdg!5ts;a8WYnPP!N+p`PUaOs(y9`EAwu={SSHLO``4!trYHK?>YHLr_s@5($ zOIciOxtizVTP(L(EQeG+?)0~vC5)d0NFCZYW6e7(EDwb90SR-6+S(8Hwz*{)c}Bff zDcQE9w)QN5B2|+L@cLPp5o1U@cI5MqyRpRMi4B`fzq1PHXC&b?);@o&E^I0 z$C%9JkbZ|#o6uomS(b;5i|}#`!&^(4eBsbhRY8%woRs-M?kwQ3T_nat-zZ*IZ;5)G zvt>(Fm5VBLMTzNHquFd8!NXOE@mMU&m;31H@4*sG*d7w1q00i08H$SoVSf&hi6sfm zArGWrbaXjE-lwDEtO_9xsA3+00%uf^8t^h%abtlxFu6=NYNeO)>$O z;)At4Vnd=MIW;9cef8>mv}i&?2Ovi8-o5M7ojZ5#-FvF1r>A!SgJow|*NNRBbvB?J zNr%Zq*nr6-DAkbQv@=}c zr%2WoRt`gvOJkV21pE0lfZ%AEo`%{3<3o|I^^3Eutr%<*IXj_`6cu%JjL%PZob2!G z9_yuITt>HhVr0@{$x5lMtu08j`hKOP0~hfOOCfQ)-6K^E?9=0*t+m4u@JMKc_vJr;4|yvGtvs%k-CY>@s;JTi^k!v9a9$#QiaN#G7$FX z&_?9YULi#e;OY_+yS}b;zNCTsSQO5UpsHwAO&z{~9Mj=B0 z_iU7ayb$^B3{B6>-^4qUSlYsWMm6{5)5tQRp@L7-(81 zol2|HD7f=AH5Cq-ajp$~a=~qsIV$|Ki0=Wj(#w?3C&dfmib>%m7ccst%BKk~Br1-7 z5fF>#=lL45IUzAMD?dL!E7@j^wP^T&SY}FM!y?2>GL_b7E?m&5xKqSH#8v4oT+pN; zZMv^57~Zg;tWe&F44VVrX=|P%I@)m=yceQNs@*<4eT}qX@Cw59^QgJZZzW*3$U>6% zdM$q%OCZhVV}j*;83h=P1b&Twd+W0SMS%q-aC~@76lTgdNKKV(WRJo#K**-U5_10 zOB)=7;YjLP3gi0T-Z*bTKrF#HThrId;UQx8)r8K}l5W8S@1aH+y+9+f7o{epq@>s! z<#f1-TISM*P1mjjw*+OIxLdcX%hq5_y0Ls}>sGE+C&we!IJR!3Qe0AtB`z*5A<6ED zHyVvDabsh4ls!#`rIbb=?qo*LWHK75%hz5O8#_9BWeSjWu$1bqZX#LXV_oU@78vZR zTvgt0G}(oEW7u$~SF%07xa@dG%gVxQsokG_c5Q2^L+z87T}!3sNF5z#?TawbHPkuO zf8Pr+|& zEXILc-)ES*E*k4VR&` z1osT896_o=gU%36FB#UaLJL$64;w4cb$TwP+ZL3X;TClE2t<>!=RbPwwb%af$7e3K zHMg|1bdJp&X=k55aBw6Bs`Hv{Q`0;i4+j?Zx*%WPxsnPrQ?aJIdth{Y@4f*MkE=#c zXw{s*0M)5m$rteQOK_>1EZojFj_gbeF}=?CNkq6Pap*`r7D&VNVPpM>fG2@EtgZV3 zshE}s=3>H~5#NK5neV-U?@ZhFk_5iCT|#ILshyOpTX)CSTi35&zk0byWYlq>=5a-H z^YiT|atoo}Qj1(o5*_L8*s0@vZUJ|@DmOPbLC&5y^!D3tn|k{D`{!9c%fvR6nbRH< z(|)bg&f*n{o4*RhL17-3o)>cW9Z5)?WVcz`7&{WIP=R$M7xR&;=_H>+=bU5IWSI2K zMuev$!WRSKFgsR~hKA0~$qB$`T3|eoo(azc!bzZ&dVZcPzx(>~HET9+rtEMiXlrZh zrF>mBY~`UNe=xrt`Q@E$`}XZSawH)kH7zZca=TEvf(QmN+qAVY0^#E*dhJuiyEuSB z2y&95P-$i$0cB-nrFqyZ?~0nApPrnWi;d+ZIl=YS4o+;WfbE_S=k4HXJ()|JyDBQW zu27~aH&-IL#&Q`tsen;vFBc3mV`2E$AXZH7U>IBgj~g|Kz=Ra0%P{%6Bpm@0mhN)- z8L4H;W#xqX%mhn~#3nKf9l?lGWmzl^?axAdTF4I^>&IoWxCRv(i*72?QcOc-o;M#d0ipYJeS=oGN{JR zwi6DYpW~_wmRxXauEn6@&dn31Z$CYvGKjoks|k@Uq7Pue^w4^ez(r+;_RzuF>VYNa zh_lG%#O9)(vrkiBbHUVe(PHtk+X_=E_&O|*TY0mpB2sL^LgIs zUFLP`SHrt;^&;eT#i=Yhy~&`!nwv-<;HOkpR;KU;0udI*6wxtyoh8gxn2jut(XC2! zvluOB1Og0g)TzDDjpy8n6BmpiSzSp2mF{vyvH4IWCPbi#QP*;wP$_DrSuYHyM}DA8 zV#&gaAU8gx^2p&T>WLxY57~$Ih70AE(^a4M+CPS28ntv$;Lo3OK&ID2@xHw6pSJz& zKa}V3d}d8c+fR+T1YV2f=9{70gxPy$@Fldw>4hq^}3+nm1xTanUJ3VwgXNKApL5(YiBhz1q$R7;*RzuKr5)6c<_k1|;?b9Jx zkX2&EL7|ZAo*Wx*Z98}B=s{}0#dbO$a8mz8{fc+@%EgOU+~b4`;qR#j!g{NqO(4wU zV9J~5ojN?pAC?ctk{y1ugx(J*B`1a_F{$fsJ+$ut7GA$NHlW4`pVr>q-a5cC5Wk}s zv5q|t40i5}W}n`3y!DwvJ6sk4d5(p*`0e3JT@C%ZC}QkVO)NY5bSZ!AN=J_t`Ij z82tWKAqGoW7=s{2OpiW>V?10gs^Lm>!3l3ZI{9>P8~_5IUDOcP{=t$N$d|{2*b^MA zF?7#8V>5lnPYw*JF;k8fYN*+>C%ew}a*ysL+L|IBLwT9%QMK1A78vDdSW{M2om}p3 z+pbA(Mut#Sr$Xb;N#fEou2ctIpbn9AVdyZ&r|F;$EhAX(CxCG!j=2*K4|}Pv%D3!N zfJni}EgTw3aC~J34%J+|fm;2YV0iGt=*VDy_vw%R{QTQT@$@OvGBYzVOKcw=7@6|0 zCb3mdp-4(fie?QQ{p=G>wA}4h_II4v^ZqBYiKBatXpiiA_0zeivhvE9qz^y*u-$D+ zN}^_5m00oU9lbcx)!s8YJ|l9swziJ&WGt3<7Htw0qs3q0>^O%rBS}wB&q%S^gtPFO zK6CiMkzRLNTGZT#s=4{t$<_;l{J1o|etZCG`$TAOI|~@0au^JgxtGmsvl;bWT_q(& za<@J{8jnsdy|@cc+fcWzq$CTPp^M}7j?KDE5{bnGbi_hcZ{MUhB}FhLoB?}ujK{>J z__)WhJ;|bNwwX3WbF7V68~N-!7BG%%ssu#(KxcMFRtm zQPT{UT@N^EzS?zl^V95dV+Oppip=`N#4A=aK|F`GMY41+jf?8AcJ!<@ZYDO9vz~rq`S~j zeaH9iP{$z$hz77+&Jjdgc)c7PQoWwUztWM!Au*Qd$6Vc6q74<8>gb0>d@ z=`nD2itj7aH?v9du+boxpUDvn6ThF>Lp$F4_eTR`9{0?oF)1@G#Xa3WFf!h8b~sH> zeI%epi2pHHEE&OI)e1YJ5Jw3lU_kVE_<#2dFOtiBxYF$zsQq~;FUL=|be`|zqL!DT zLrRbimKZGzlNKyc&seTRk(I#MF7#5O0HiC>IYF4!yFcHvCnlz& zt94{}ar#RmsD21B!YB~|aq8TjJ$ri3(?kwH21q?u zaS23+$73s=;m*>;_Cpqn?b&&Go-07~P9^8><(bSaCc<- z$T~-UzxpsEOMVJ(Lrdnwx%<&^o!-4~-)D!=NwQ+n3vzRNdk0yjc$iupD!1EOi8a+Y zBmd$+XJ$opHf3{VdA)L@dSVdPwP~4hat2L|Ba`Oo1|8^gi-f`p7Y3$1GPHP#kdTNaaKDo|{7e1FrB+#?=d~th4z&~&u@+IqK z@~EriyCl+iUNi4WBibRz88pvJ24S&=Yw2;3HmR_(_w0a3KEZ*{xNVx-iD;~@r0P(D z4f>zSW-B*g)^+UAxpQ4^q92yzu_xTAsS5V|yh0O~>g!wnZ($3ajPpS-HIXu-Dsteo9P* z?;PVG3nQI01nop8W}EG;eO>+iadGSGtky9J#*hS)f-^H_&B-z|5Ge7~`EIZGaJO83 z_G}9aK+d{#0BBcDOvt!?2x){jcJ=f?2R+a?Iv^E8T^k;bj^2g2vTAr(E)Q~sTs}PP zvv>G4&#?i&C&aBNCz~Zab8bK;r3PSSJ~QZ^VS{mcTHA*w#%859?2C&Pi8@c>Tdz0< zJyn^BGP^R*pYIzs>9M~|$m$rp2w%b3arCX=ZM_kT=x2_eIBz!3xl?A*YfX#zT65U= zph5`@WoW%b!-7J7s%0cEN~_1CQKJ}*V!U|iby%WLgx9lqZ0zFn?3`CF_sn~w#KFxd z^~`zXa_{`?^u@8ul+E-H$^a}T9E9zshKL+E9-@Z_Q#=6blw6KF{mm`&(12U!4Qj3l zR9c8kMRRj}Nns9IhA~gLc|xI3%wfnBS5#jY98jl*$L7-0B{Cnq^a*|+8^SrZqetKS zWcTjfM_W#}QH;a(d#}8EdRk)yv7%Ib8|Fw{m& z$nQS9>tFAF9PrwvOU!O*Y4eN^!mp=)RGPAE;}Xat+MHucv1h}Zv}Q>*U7}F>@w@-p zb-0^vL`mEWhg;rwWnLJ}QDXgxydogIVtqcJ{VmEN>sg+={LPZ2>0BH72h@%zznwtYP zcgcd4X>LAx&_~5OQXY7#SD4{YnzBH=1?Y+Ln$#h4$^{6^@CNOx(cV7v`GMYX3@}&< zuFNtHoxYGd8id zHlW`=zVi6IQuNiCBw*3Qi7@Nqyt-={63{jy>Nt&CTiSb84t&mY%TykIv#IsE*$5>V)0>LUYf@s zmwl`6DDKsfPN~4flSr0(VIG!Zf7Oy1Mbr z_f?Ns>gq<}ZH|VGfRqV3>BA)5j~NPTl81=5KVx`*?}tYASTreNxrwNoh}dW@sUO|a z#5h+{US3#K?4BL&f&obe`xGlC6;n2B&6D~q7ygLONaJOgbQ?7;)mZKw2PQ{8rvIyw8X$EXvn(?bkr zla&k2NOJN>1U5>SinQZ`7!ml}#bOoh&Y$kEL_Hy1vu4fub*ol4MrlPZcyU<)6A5$% z)CF@7pId)KVIeY*6tNbZ4r+w9YDyqX@NH73rc#&UTU@MGM}P>`kTL>gFt+Oq9BJ#q zb`*vVStvJ(&|yJZu*Bj=6_b+-md;4JREy=~us$jX&);S{0CYLVC%PmaLcP84wK3LkGH7{-~FD{0HGh?b>LE_+Ysg%ogY&HgZ z3(nIF2Ue9b^!HE&-s3Jjg9!Ejgce^vW3z-GAs^!r}qZ5&Cf$@FfuxLrw?}a z_M)dq7Mwjw$My6~&-9!b9_pVQM5btZZ-87LZ-T;>q}TXjuE1jC&ANqEBs z!%ghDu1vKNl994Zlg0lyapxaag6xNgOrFj z(b|lvaJWoeq=CB7Y6vYG3}?k8z{S$XSwxdCUZ&_S6bx5Ne~FaG6DTsQorCx@wgNx3 zXdUXM7vI@`9#2ajJG~m_?yj!V(#%D*jzm-=Pv86!>fR)yRjBc)VN8=IcpMjorYSGO zWCVA=4n>#8nHdB}^mrAVX)NmF(Q*bp$1}CBd6FQk zKZwi`84i{ZS<}cc(|-v_0WQjCvx`i_=vQ=_CRSNSn0_@Mf&1k#@%LPEK1$kIL!Nm=-ylENqi3Vfa{0x9*9j<#YLjgK~&qT}(y z8a>_H(VK#~i2u!xLtLvX3VkNM9itY3&)WDa$hPMsTFk-gaJ8j?+0Y zF*+kQK!GwRCt|Gxt1+^)`ufsrfYZ;)CLtAAlIb4fL0q(=;fXoF<&BmHvi5K_^1v)T zd?mSKT8A_k(>hG+OSFh^Q8mnH%5a~AZhe;4zyBvZ*1ryZ8z_r9|^88E$<9@G2DC zQS{MW*lsA-n&b6laFBqXRDoGAb8)z4d4a)Tx9XJwg&WhgK99mmR{r8Ep?$N>rInR9 z^rqD9o})x;+UR2KA&F6O~zU)dm2Mpf9tS2Jv~O|9fhH2$SX6ahp*z&tF&ER zN?n}Mtbm0iCe9?YmzI>UN3ig)XN+qqa+Z{2swu839!4XB5kL(3p?NIXdPj#nX=##q zi5tu?uT@#?;eDhAc|s*87^XeaVbB|t=A>z3^RP))XOA_Jjb&DQye7?ARVADxhTCaz ze8r-ws(b_X2&8&Klwe~NvG#u{9*aR^+kn$qlMja1^sLYA`_Wy9$co+@XfL|7aVPLBvj=9dS)GyR*KMELqQGq96^n1U3!WLY)H zMn&M82#wtjZnlsXI z`*7I}C4q zvn**kc7$Ox(ySf@HbO_1F&!Ig-e@AxE$%n$YZ_aphC z_;|fOK0bbaR2e;R=D>a|UTD)up4eEjYLQpW%-`Amb@n3Uj6Na~9a%VcyUU z1iuz08^DE#A+u&TK!>*aTts*}5TpV`H^i{9$)=$_-Sz%QBBs9PEUGAp;E|WqNi7 zq2*;tPebY2**3cHeFuDH8U<>Us3sjput`FD4cZpF=K|3M(J58s#*Cf8@SLCD(mVPo zcIRW|dTx#-muIBp;IrMBj43L6^rBzKVx-56gbdxwfJ2ihKAyToUk{@vZm;o`!la6|(5b|UzEoj(r5nRyV`Ib9 zGH`<>M&iMQgZ1+Bb^cA^vx|H)byMUFA>Xt>+(PMM7DyK#8pmM43DkRGEQ8ea(S@n& z*qx?mG;1?pV*{WMZNvgXnotM0a*2a741v zmOjFzy%+l2GDjZz)I5jG-FKmvFn?dms7%BO+h-8OiNQHO0G-jnZ3IUbmB?sJOqw8< zXn~=qJ#XydF|*?{%1dma&gy_wpNFs85zFv3-CqbjWG?{3AVoMs=IkxQ!-GT-7Xy6* zhO_xexqOz4eKA}zSy!oK^VDp(q;LZz>Iii67vA~Q_RrH1>tgNS=YpUyxYB*`?6SCc zPO4CH%2Cy6jH(`k=}07(FI^^=*8oo!>)B_+v>AQC9>vu3TEnW#<}U=U(PMsej-F^4 zVkO0!td>|BFL*~Zsr`8E>;xI*82ZkfBnUF&7;W7>+}BnSmvQ#=bbeLz>1>>~)5MvQ z%dIZH_f1OJ81PGDa2Mk{LwUbUh@)8vOh0P}HmU>fz4p#qfBWo&O}6uGQHtnuYSg88 z-78PewQ=NftyV7owsKDxXHFnR=-6&141cZ zArZ$$b753%ih_rL%^?SBJYEQev;u}cOr6hN7D5IfG-+pMs7q??b!qDB4TdY$d^%7r zy#jFd>7N`s*4bH6e)8m5>I;`B8Eg#ihvNA7FmCF?w@`X@Mua23M}{NQ2g6Y~%tcJ^ zyuua&>>&`J=^{80jJ^Ak~y2M2x_&pL6p z($nf^pMCa)4|aZf?kr}CpL{Yf@GSKyJG0e505ua-#Jyf{Q#>p>8oR6lSh}i~qi}{JcrG#G1;HP&r_6g~G+h9Nt}7=|2g`-`^Q?{A(ju z6T^&mnA_#JToj8LL#`&@wWKiwF#7)rEm+dkz-K5)s>Vq0<@0=%a?7t!E~Duf)99vR z`8|~U@XY~w*wKYVo|2wfskvk6Xi7a!NWTpisNIHDWexR|?3Ka&`zg0#zrCXx7go_S zig^S0mrdPJMLp$jbD}*tU}JJbI?0V(9EApL}w7 zm|H>3VOa(}xfH*PMwhCRI2)NG6+o zeXs|{Wo2U@e{*y5`3bQDcE8F5{`h$W5t*KBq&l$=6kBB7CW8hmx(fElg^spk?ML^c z#6FhC4p7blOIDUOAzI?o&-^qJb^2@Mj}Zo9{3Cs*uU#3g5{n49NFKhloL~&usc7u$ zi%m<$94a7$y4IXjD`M*`y}~&LZ!eI$G(HU^2cuUo1Mi6K+nv{>hISSLDZ) z=zcEb3>}?$2#1d#{xE~LXq4I)#GBr&eL0-y)0xBZl7`|n=c4@kA;NaOTPu<(;`*&}!+U~Y z3V*p@8PX|6CjI50WM2#eJN^>bf&f)%yHLv)AH4hCd+*6)GXB{4PyT}EgEJ$11QGe> zTc4fjorDE`Zf<_M|EE7)yqK_hsw6tWnv`ULBXC89-NWjGR2^&{=;+`6lk@sKepneDKj> zzx}~aBObPxgj0=sNlY*eLr3hIk7&bSic8B4lY)wghKY*49Ihfpfpg&L9yH>F&axxw zJu!RKAIkaGiI7wbwZC0rXe ze+izAcX?==s z1qI-YM8)pi3dPLScGk>HxZXjuTZS*_`Cn=2nf&$T%KEalii*BI45@;Vl?sJGa!lv~ zZ7D@qZFQleGR2<3r7FIWC3qfZMO2@hrKom=^PW-ehw87-xSYjf(l9li51eJ~Pa%=x zB1)Q*m|4I7tZOE{9OyCqP54&>s3RF3w%g&r7!}orQUy_|TCYM;UG3BQ6OlsI*SBIO z=8NLu>1l8JMLjO*!E~9YP;A)(-H&%3ez>izq2r7?FjIkj0%vg2`>fe)f!#e{A6w9u zMJcdI73{w?W^~ zlt34s4L`8*^5X1x?JP_#J+s;j(sO0Ni`>Ry31o7yL@1J?uaXFb5~)%qV3n8Wny1dA z$7-80=aff`?6Rat+FVVHG?0rKFSRm&zeF>+wvzmGyHYNu7myKXO)N=?uP$M+tIEQp zMT?~2BjTmy2WztpT$yt<)Mn{)*pNW{m~=W8K%lL^ob+S_AC2$ih7^LlT7?4s-pTULGiT1=ygQ{r z&6}K@^!2Coo~SoP4a%!*gQWV}=GU%wv?mRMThVLZUbmYyb+(B+SfA zVrp36pvGO2p&<&3F{as5sGKjqza8b~OZuBY48!^2zY(IOer}=nNG%vsNK01f9)8aJa-J`HL+L~5$ReY(zTqU(+&F5 zG4(u!$nd@^SU!`26o2Iu&4Cn5Pu)x)1$yc_P{#_sAETs z+uh!rMl@V9#}|tUwEQg$$-G$8mbjYrJZ{lo0NioMK8 z9Hyn3kI-Ma?&iREe@jmd`{~EjZ-yH7)sG2J1j7Ct(-AqQ11Xps!Ei3>DmTlfiIX7~ zDjWv0O|xCl*x0zTK8q@KxwKx23-OdXO3C<|f+17`S2m_*erT3bKYssHF$|U#AQ0Y; zK}bXcxCC!4gEw4_;Fp8rU02{2wbg}FTNbJWMYxwd3ynO9Tm5xna7jct;kX1-_-maI z$m5Smz9fcwO|)X<1*Zg;5_u6v$V<^xlw}R1Vamd(t8`iZo}XS-+HrA(T-#B8FJWnD znp0B>1=GTIP+9e^s;lSUEuVh+yYbnV_Uz*SnR$bWte@iOJYxFfnZB)1$lv!tGXguw;lrnky)$p)=n7&B z7R)OYb#)5Gd^m?rk3`~gZ83hS8HwP5OG}x$3P|&TG;}>b|Mh?F-p%C}6>>PAQ#)NE zegEFQm>^l|>m#VzyEC#DJ(t7;(=a&qp+tsnzYA&=&LzOI;O;yzG7B5}h%^=VE;(@o z8dsKBhzGlx-P7*b$-aSX%Us{s*hnAOic`^+!F@U(do$wH@zcxW!#{A2 zEN8}4z!wdQj!Gzys46QHd2{3A0(q3mURdelSlYT}HhxrtHmfhjIdFizmnC6-|0 z!81jeRvvkT7j>euw6t08GJ2Y8_gGcM4SJ?!h#n$ zSr4{RW(Gcx5zQ8Re6i?heSI){AY-MI`UUj^uGoXmer<|k*W$P_-;9ygRp5t`mmOQ2 zZ-A!~Rww(pS@7ScbS|wLT80sKbXt<))oSlN1_d|QzdxUrm&y6cIB%a-l|*ys;ctgOlF!c%$pd#C;JeM^v;Ni?C6nGa7WL}JAA&M z9c9H;l9b+6Mg0<9ZW=`l)}HljYzT1i**FoKGe3ndKImmABc?hx8rx8EbH~Rgqij5$ zF(F2Sdzh9iX{f2BmLn6zR$98dU8>I3j+WhHBJ50%lRa#DOPsG13rdl7zx-NT+u&eU z>V*s4)ESqglrIuF9J2a~iohv$PavJ@#`_<*z6f`qnhG}GckhjLmFw=Sq5gw;mG?B3 z)!bNVtJFw;C^oiEi8K7uV2TdHeCMoD_RPd?GnFH>0Mew6K^=bdxRC^z`=jenC>h_m<&QH1hrlHGCHDpQzt} zHssyTx`C!j&fJ{N!sA&B;Tf3i(9DmICnm;f_}+^^;B2o@$Wj6WEnl3NAQvgbBjYiN z8s9B$Xg}ZDE7>p!LKK<4NUfr_vmYt?K{fST>Ic-z!uO-FD*+q8-}lku24JqcafQ*X z&&p`HaUD)>TU=Is{rU~7B*{ede!<_idHI&FJ$!#1mOB1IzyC!w(S%9xcw~A-c}*1t z?ldW{4Hwm%*gI{p?BB05;`pefcqJ=F?_sP)TLDFUYhym-Q_tt}f3O6X$o`so3^acj zSIGUA0K)rI)%Q0x6hk{NU$*)72OoLlk%#Y~DPo_S^9q$3ogRKvi`JIIEzv6Fb3Ai- zd6|jkC@G8*(kOFcW*!5J>~Q3=voiAXoYY^bzp`$p%V^>qA~$vpGBWoYq*1SNrpCp^ z9K0s!V{W0aVW2)RTAAlmgp+B~)~@M#!EW zK_56W%NA;UcO5(hIA1Eu8_JVYLG|3U^vVr)R#8pdM*t4D|GB6i@$Ol<@|Ig@!2bi{ z%0?r%~i`BNA%4N6#cEytNoK(^sX5{3-FjikeZFITJ zDlKJ`N)RiN#B&T9oI{?Wl94TfqcT-`D$~-mULll9g-Wrbs;aC^NY%SUNlAU3XL)LM zQqp70xK|38F&a@8Y4DO-T(K(_D~i@*L&wVUs+H?juB2{vH5roCVv&0g#rF>mUIbJz zZ}h~m4#*Fuge|(NPP9nb|Avx#ymTAGz<|N51im zZ`@4XgOx!upKn8NW{Z}vyk4D-?VTa$3j_`nFA_x?BtofRSR3Q(2ZP=pOb;Zsi%-bw z7TGtg*|bJ@Qm$4@Jthy?1C6o29yPL-nrFpj`K9?;*sYsiTwazdG;P{cRaKZ(R=0fP zt+(EKJ#{C@XpM=XyVPiS*zklLmRb!iHpV1^yUlt(Y-j>edU^yPe|!%vcVcn59KBtK zZJ|@s>9s3rYpRy7TE3(r#*kdRzDdUs%2W!qI-|gWT>-eQma-Yxu>H~)5`CMkmCn1k zixxGIqq}(*`uo-D35J??;cH>C0X(3$#7d-6soIj9UyUP%8=U!d=tfJEE!bwAn2}6; zEAvvU*ngvOkc+^qiBzM@mCF^1<;pphS{G#mEsfabr%>38VqB+Wu^5!zsfh^>TZ#4F zD78QgYb%GZw8b$TLwhtNXoMOqIu^i}856UrmoHy_)0T}H*b87u$|^yl7E$Y6O>u*r z6GSglqt6EvgGMeDODUsKA>u$Ue6tdyyw6UKO+4^wbO z*ytL{+u{q+Xf$z>_yT5bH19M3qpRZXIH?qVzuC~}bL?cdiB^H5i?R16(UA@0lC2JZ z9lr{H>!k{<(F_H+q?$Nf;_?Y;W`Z34hcu4Rn!@;7ARV5X!UWb2k)DNoWe(5h@kBC( z2AdwEqGQNFXfZ`cX|)cELMr0fY-SyIerARv(3x$CF;P+lTr7nWox|^md-XgXFP#~s z&@ZSgxak$lkq{eg&>L`oomMWy#w4L!>(}7PteSnCm*S#$E>>x&uV)n_wuxPzgE8zm z|1JPuoD*?xg~#3gZN#;Q$JH-I-0#EV?s*V#`@`etbkBsx(dqVu$K6Tp^W763w`y4^ z3}yzg(Kmx&LiJ&x+rvWlgoW0Gh3*Ustq%(=3JW!cg~C(*@4%%FZFx1l*#8@uLwm3P zH!}Zk9+`RIX&1CfM(bcg|4-J16rn^D#dR?{QFw^V7@Eio!Z&vN!cT{Uxg0i>Lykmp z!FQJo(%>#o+;PrHg7+~~D#?eL$kYCr2t)lxTufWQe#LwZjF`lZ=b|2QA%Y%Z;m<%@ zxLBLBQ3{R&#wm)pfpP33qx5A65hO~xV4#~;HZ zqlwxP8n@CfJ4 z;o-NO-Uqz*^Iq+ikBs>CG%NwEgu~6dFLMT3yiK3gKQUpoqQPSJ?!VkAXz#xL!FJpu z;G0blTTMb<8rE-el2Q}6xLpjs2TM0CPnC@gU_r8@ZTL7erVAHPrnRoBvfNonCgg>s zn>S~dr-}zL2NEWgH>}&Z8kR1CD5LcRtd6NvDm&3{bwo=OKTVdSnUym`w-T4cP%@S= zn`t#V1*Kt)cm1E{t~{!(>&!pBC+TTJpbZ2P5}SqCWHuYKBNi`s!%JL}CU)XFuG{Hx z(rMD^G;Ic1o^;wwrgPe!)1I96w5NZhnNBC2=A_H?OtUmT-eQ|L-oRiM8M6w30ZD*B zn%{j3Alu2<=gc1?@oc^O?)&ck-S2*@XqUDb^~)^C*XxTMdcCt?ON+%;wQ2L_@)|^~ ztFonU-m=YZb|VZXP8p2ZJTt?xhyuf1Gc#s0!+7M-wj&ROn&|+~4Q|MF`fxtgAfY^P z#KQA~$rDNbVK;T1dL1LY=1E*@(q=IY$k__>K_;bTnBY=nmNTnFx@=kw3D52?1;IDC zx9%@Rd=gj6N}sJmt=7!1PH}!|b6ri_$`TXH_Ux)yomUPn_eY&KZVe3l>Q}%1?e8%e z-^9FAFfsA&U7#vo8RJD{*FX5UUDt1gqVUZ^m%~r?mE` zcjTL6vv9LFJV7nFH*JFXyxEgv(A$e#8*p%QW#CxoTAz9%fi>nJsU$NJK^+tM^&QtQ zmN}t^k@~;jg}C=tMlCLNEi|4>-X-m&jU%gNaH$=V{rgu_fiL(}F5dWR`U>Q(2J?eb zH)@ENA~mx0T8>XF9uk^YThIZ4&0hMnFlZuHMu@_}Xq5gYW)! z69wxB@}0t=4nF0`-Bha^6N4ZaVB1v^bPvK5rnHc za%apl`~AkcU2vGuw!vJv2cNbElOHU6+wR@&jTJ3lYt4o|hzT({zxw5^_NQL@yT?jP zEfz<^@doM#nq4hhx3)aBRad<4@X@3E+~CS`xnwub6K@xT^bpZnzR)Xox#+v{OneF} zDhigC3M$|L4=W%3;Ep z{mfDE=$wx|e(@rL9>XtX&-o{|)VbY!*&E21Kva@Tq*A#Mp6q?OiFjEfU;5j7xH0L_ zx!rYJo;cqFy&;<|bn)Wjk2z0rIOibVF4Ac7Hj{pDDFYlWMTTQ^FaWOxzESd<(gJE( z3(2uLAO4LBW*24#E}i-Lh3WbE&dv|s`q}HDOYgxl791RxJ-n~2z7P?Gf=gMu9Cpp3 zF!D!Gj=sIx1}==zUQ$!ja^T3{;PWl8$C(#5Z~j^X>7=>>UOTQ zMUCCDD+?ikZMMStwtZo{3nihv_b!HB|Jhp~bao=5`-PvMxim1dAe6R7kKif>q?NYO z7Je65Gg8`2?blm77z+(v3D*}HsG}AlgN6pD6AtMP?b`#c-UI1^7Ub0IS(~+i>X08j z_Lb+J``qW6Il;nD(Elf}py285B1c-N1k!@Lk?}YD2of7N4fu+cEnA*B^!fJo=bt~J zRwbKDiwpDe+-`8-l-HJNOKDlj`ZUI)y0+$qy2e9zr23>@yU`VZqgBK+lTTicyWyds zKd87GxZ`SoLRb4smX`>}Yi5re!qJKX%I(p_1LSkc2wtRE1&t`-C%C>|NkM=;K84@! zS;=i5KT_hoRk#QLhf?gUe|~>u^5zE2>Q_AMX_e|UxrF*?kwb=umDH0ShCC9|Q5L8| zpNF6E4}S-WgX|-3iPWdCue{};HdU5MEj1M}99R0AO#WbSgA9LCA5uIs~tW60gPnpOeVw*Nl95>0Nrf)+?*U2iH;mf zNP%>?@QEjO>?kZODakF~1mpx>*GShy$>g*YyHpW^m^FyICg?io8gn8+=o%wR$sadL z$MR*NNU9An4*3=G6V$jqGa3U|c-AO`?}|`$MnP#oa$2%J*}Y@OjxALty%pqj*2F;f zKoGv3gXiCS@4Zu(Lkm;z>^e4{pn+0>(j1oSQ2NJIf~Q_nQ&DP5oEskKmnUXJf<-}g zI|j0Sj!ibCZK&B;kZ&`|<(Axy8;RGr^;S)iGB7$BUXW>1MkZ%u26laIQ*$~TEeUfS zt3k?8#-w!&C^sQG3PVv6s-Bwf;MO#uFeN^p7Xirp{u2ze5B(mA(TE}O`8?zSwb@I( z;i29T&KHpALuyP8f>NCN6v{cHT^Tgk3<`-Y&*aps;AO%mRc3&j-`d*PKs}9sZIv~p zrL|QeU(t!@N#gT1c*OqZH~;o|tcQPvE`g9!{9kCx%6u-J&Z0Nu3G;c)-cU4MG@UNp zlDA>M@Qhn4lpL0%9gI##>1f8r@OX;HCmLZD@NI(4B+1Gs$Z8;SaIk|Nlj0TWm;jIH zO(DjYUgp$qA-{tG`-zKl0Z zhVlcGlg-WM+Pb=-$^yc-)}`iGZr+Q~f(Q0AS5`q+p|GZsL=$YP(j7RUL$Bdn=cay3 zy+z{0Nq+PrPj*pJox5m7l2F{zP}JW3>{i7DnDBnZ)+6npokZR{kJ-Mp5~2Njs6)L% z6`O|Nq}k|f6fI21b`&-v@G6#pE}uw4cD9AOy5JBL3kp(aH9{Z;DaB;><+J~xvlUli z<S$QxMJU?A5cw&pt^X&<2}+`2uC^42fiow^Gb zFTkv~6f8VPmHD{V?dPj;Naq-BocwuawM_{rxJ?@y<22588j(ol|IXAQ^@Va zQ(ad2{6d-~Ju8KpS14i$rv3Y?GFcq$*vzW^!YCa>S*4?+v$KyJRaY3MM~-7mDBOa$ zh%SHm3yEn%J`afKVzqkijp_p9P>3%{j2VyqzNjurr69*RPDTyEwYbkCjhYR@NR>(& zUS1AA(%|vI5x21@t`s_zitaoaq#}! zGlSnmXkM3}s*GSQ&&^d=YeEa!Ob3och4-xwGdqprer(3Kc70T&#fFEIlReURZpEbH z8d--v(KQbZH7_>q=$pCT+!%>uXTx)-2V2C9&co)~Y+4PZ()_MnhurBG-}%FPBPlhH zZ^)rTs??ce%f|Nh@p0Gz;UeB1(%zpm!4g%ayE52^kc7WJ)zCn}@0KCeZWm>#xqzb{ z8{4*Rvr#7d!WRy1{wJyh>g6^Ykdie$o%XPX>@0mE{HgKf){=5{S>@H>H^8o9!Kh0t zfE*d9+aH7u02dfH4M&blOG+$e3H5>_a%-#pnD`486GAh1Hsu%Q$$xw zhz*aXiZRs{Q)WnX!5(J#F(RCY)U&_+-JgB>iv7j*<-0QDq%OH+-@ZF{f+1HXzM^7s5!*ErSkLnS*1rVN$A-I>X(utkTgFA&7o2W^i!Np zysvj`Xsq|Nbb+PV1)eflDL0O4CtSt^-)$e}*$}?BN%Yb0b2Un$R-=r|eM2__Hv*^S z?1D0=4hwZ&Lm~Nw09C4ER!#%t)R-`r(D`SdZ)k{IfU8bSj6^^$F$*j!p3OOG&Ieb@ zLLPQOEsG#nU)*yJ!1OeM!7kwCU__=CMv^N>d|xa_gfShBPOr@;!Xf{Y`iQXLuQ4j* z<0v+fI;RzUKca04t|zJJw9xiKDC!xZ`6@?_$wNkSz4O)UA=t% z{FMNHoi?&+nZ>y-GdGFp?VsqLUJgd?bP2G4D^yCn%SedGgO)ZMjeEb?O#PBNgYU1Y z9%bLTA!`24!mSVaxuWD`BSfXpcc<&LWPug%x&eL8-c7Evva-p)dOfn&UhC|9?KOVB zJ`SX2l}JEa6TUi1!iC}>bwQoUNA@Im-4FMDh0FIA^%nITd|#K8cSzv|&xn|BA%BP> z*M%{2x$eS+fP7wG&es_;SHT*j47lL15(;)xxtjTT4VxS6Rxc$bEpan$59+V^{oB8L z>ub?U-gpU$j>RfKluedfA}la5UcjsSXP140JLdx zGj+=dBVbN3MPu08Sh(uUx3YZeiT<9et7~xdc2`%|Kp>iAcGT`_Z*SkZb$5HaO2y6t zp9X22Ijgh|(Wo}%VRI>lpup|kvb)xnos*GQ-f3(i&%?ojUw#0bHW^pTUkrDj~|siwMTx{cAdUX z3p2t+MpCC+w@#;HSvU1#>c^6oUv@)9^E(X7LgPD;ZcFbxNA#gDsl(e^TWJ9@Sxu^+ z`c=O4QvXkX3X@VA`?yzviysC4bdoHbPB<%i*=+v}e88t6|M@~PdU>?#dRS)4gGNtr zt`WY%ufsLXl!4r5!}8W+Q6wTBz#_cC&Ic!Y5t`=WyPzO~!RhJXV$73}k&&VZ-s$_K zXGWc*Th)e8|Q~elc7!Q4J$ICH``6_4f4*_V;yvg1A?gyZgo`C&&Bx zLZQKaEZf_?*FO2=TJOm8^yK@p8SdjzEeEIk!$U(OQ^Do$sQU>B_|u{**61lm8s6m%o>PV@prSQM;PZE0_&1r zb(YveB;f~$SP)soUxC~cCqDeke5H6rgPenEG(J%k#WL_mtDfMX_VnDeMhEbhl_@~Q zsh6ThsMM1kbSgE%uPlOdI^;;C!j#g>{ZLY2zP8Xmq!cqV;HJz>P^ObidUbWWhe@}m zTlsfcK|Vr{D`r3ja-+3oPZ?JrU23DO;;JCC_mvK+mWPgT+r|cdYB%n}7@Fl2M z=$00RaL?s}rdxcj1mrpa>Eg)_X)T0VH~9LH-IWDIh2leGmjP&aiwIXs-k_aSmsbYh zdB^hv_}n{ypYmji_>O&x@GJxGB+Sm@2(YPcV_8!(&I8_rD9dJgC1pT4TMJs7!yp_EkuV(z z{@z}{At%QWxgC+G%e84~xwWmWwYh0~GxYk5y*zFxM>#2)==9j|?cuR8AneH{7`bZ` zl9D!TFnd#qi`N%{by5P`1PGw^J_dHmuHC!qMXky;qf=n#Mu+P)GCF*p!ldi8%c{XzFUzp70!*#KFp_?^zli%R8vBS%W<(1(Xf&n{Ck(z9tQpAWde5Q!{nva`#O)O2#Ac=FRaXaMT?RnqH>hQ`nmb=K-i-Nv6qU220c|jxFdJN{yq_$5$p2X}R3# z^s1SJg8E(09(ugZ5Q%DL9uHS!B;`j4#v(Q6w z+XArd?5u|{vyIs*rB@28l+dzQz8nhR2^get?YxyOu|>HQZkCM&HRdTpK<{P!5tx6U zJLebjf6CffT%@(hoZ2y>;Av@5689-1yC^~CWUXXzF@&8xB{AZgaGPSmC9n}5~>I#>_ zm7rs=p^rhUY^)#o-~Aw9jiTeiY@Jyu?G)&SxIWDBq235L8u?UHtxYX6kBTY8gS4}fbj;3L)>ln zAIqt&&53_&?B>mAl%bX+65uLn%%5WX%(u3a6&1kQesIt?5e&`T=)Ua_o=6H)_Z08V z9+yaCP#$3zWt2?>9jc-iVWX@_S*Z>pO7i>He!%Q-+yg*MO^*!pKz-=+>DyCLjXpUo z)oQJ%Nb|zoK|GLxg2F=VRmDX`wGc6E-?4rB_U6{sDlwWUw_Zhj?#$g=>s8wu8|`+v z-0t4L-!1e;TrTCD-S)s-HK?gYgq3S0zAwyaIqTrwTyH4^dN1}`@){fS^5pWog2tBZ zdmaa^esJHDM_>r?Va(U4)`uo*KR5yE8ia;fDkM2&+;jz~Z{RJjH8A6ge(F zTmzJnz|(OzHtv1qnP(0^3xkG(hYsy-5Ngh?D)v-&DJv7_h9qIdBAp<4p*)IVGdu}6_LZ}ty#;Hu3mq~RxMGh91>g%t` zi%aEdBpFZAg$LxUmqj221G(0=M(i6e%QzeiGd?cjUhdu1-4{>m>ycyiT}WaGG9xF& z4tCkQ2_)6 z>x+vUV9V3C4|c9QcJ8bd{aT2t{Ea6hpWn^(8(F!A#i>zfnN3WF=jXb6?gU~m0%5#{ z?5f(PZF``saA5oXCl4P!yx(2pTx};<9K%^zvsV%my)0y3x#U`Xs#K7J3fB*cn-f&B zV>qm`C{&<$RIy+%#<%4G`n>{i@5kl8Ob?(9^p;^R6G-^Z|oY3hflv-oy z22OVbd1jYo#amnIVT3X(%uK>FP$n_1b)!AcvUK9Eq+%%=rD3uzWt`=9Ja&$DPHV+I0GWap$a`Fq3Q2R=0bR;TGaXM29 zl^Gd<0kc^h9OZM0qb@1^1~iX5BvE11*4{7FR$v_!*G^+5pADN}2g3Y(dfC;rVblG&*$o+|?U*2751@9$Hvr zp{fv`M_i;{PYx73*6M+z$u&@vu(7ZP)0k|c7KkuHB9FL3a_A5(iwG^xM|lPkCvr7n kU|-+MFT?C#7w?653K5S4NLXdus%xNr(SeJOOaaLM0Co_Np#T5? literal 0 HcmV?d00001 diff --git a/examples/brand/_brand.yml b/examples/brand/_brand.yml index e44a5b719..a9c552f93 100644 --- a/examples/brand/_brand.yml +++ b/examples/brand/_brand.yml @@ -54,45 +54,44 @@ color: typography: fonts: - - family: "Quantico" + - family: Quantico source: google weight: [700] - style: [normal] - display: swap - # TODO: Bring in Monda as a local font file - - family: "Monda" - source: google - weight: 400..700 style: [normal, italic] display: swap - - family: "Courier Prime" + - family: Monda + source: file + files: + - path: Monda.ttf + weight: 400..700 + - family: Share Tech Mono source: bunny - weight: [400, 700] - style: [normal, italic] + weight: 400 + style: normal display: swap base: - family: "Monda" - size: "1em" + family: Monda + size: 1em weight: 400 line-height: 1.5 headings: - family: "Quantico" + family: Quantico weight: 400 line-height: 1.2 style: normal monospace: - family: "Courier Prime" - size: "0.9em" + family: Share Tech Mono + size: 0.9em weight: 400 monospace-inline: - family: "Courier Prime" - size: "0.9em" + family: Share Tech Mono + # size: 0.9em weight: 400 color: yellow background-color: "#1a1a1add" monospace-block: - family: "Courier Prime" - size: "0.9em" + family: Share Tech Mono + size: 1.1em weight: 400 color: green background-color: black @@ -106,5 +105,6 @@ typography: defaults: shiny: theme: + preset: shiny # TODO: Find an appropriate theme variable to set # navbar-bg: $brand-purple diff --git a/examples/brand/app.py b/examples/brand/app.py index 06847edbb..63b97693a 100644 --- a/examples/brand/app.py +++ b/examples/brand/app.py @@ -131,7 +131,7 @@ ui.div( ui.markdown( """ - _Just in case it isn't obvious, this text (and most of this app) was written by an LLM._ + _Just in case it isn't obvious, this text was written by an LLM._ # Component Documentation From 75ab45d734c406989aaae42505bb452bca04ef98 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 10:47:59 -0400 Subject: [PATCH 38/82] fix(brand): Rules for code-block-line-height --- shiny/ui/_theme_brand.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 786055203..ef0af3ec6 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -96,7 +96,7 @@ def __str__(self): }, "monospace_block": { "family": ["font-family-monospace-block"], - "line_height": ["pre-line-height"], + "line_height": ["code-block-line-height"], "color": ["pre-color"], "background_color": ["pre-bg"], "weight": ["code-block-font-weight"], @@ -276,6 +276,7 @@ def __init__( "code-inline-font-size": None, "code-block-font-weight": None, "code-block-font-size": None, + "code-block-line-height": None, "link-bg": None, "link-weight": None, } @@ -381,6 +382,7 @@ def __init__( pre { font-weight: $code-block-font-weight; font-size: $code-block-font-size; + line-height: $code-block-line-height; } @if variable-exists(brand--background) { From 792af385d5fb09b44977e5ee8a346b29a6064827 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 11:00:17 -0400 Subject: [PATCH 39/82] refactor: ._handle_unmapped_variable method --- shiny/ui/_theme_brand.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index ef0af3ec6..e937bef95 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -196,16 +196,11 @@ def __init__( sass_vars_brand_colors: dict[str, str] = {} css_vars_brand_colors: list[str] = [] - raise_for_unmapped_vars = ( - os.environ.get("SHINY_BRAND_YML_RAISE_UNMAPPED") == "true" - ) - if brand.color: # 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: - if raise_for_unmapped_vars: - raise ThemeBrandUnmappedFieldError(f"color.{thm_name}") + self._handle_unmapped_variable(f"color.{thm_name}") continue for sass_var in color_map[thm_name]: @@ -236,8 +231,7 @@ def __init__( for typ_field, typ_value in brand_typography.items(): if typ_field not in typography_map: - if raise_for_unmapped_vars: - raise ThemeBrandUnmappedFieldError(f"typography.{typ_field}") + self._handle_unmapped_variable(f"typography.{typ_field}") continue for typ_field_key, typ_field_value in typ_value.items(): @@ -250,8 +244,8 @@ def __init__( typo_sass_vars = typography_map[typ_field][typ_field_key] for typo_sass_var in typo_sass_vars: sass_vars_typography[typo_sass_var] = typ_field_value - elif raise_for_unmapped_vars: - raise ThemeBrandUnmappedFieldError( + else: + self._handle_unmapped_variable( f"typography.{typ_field}.{typ_field_key}" ) @@ -395,6 +389,15 @@ def __init__( """ ) + def _handle_unmapped_variable(self, 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, + ) + def _html_dependencies(self) -> list[HTMLDependency]: theme_deps = super()._html_dependencies() From c5f6302bd3160537c1a513b662ce2eeb31348e9e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 11:22:54 -0400 Subject: [PATCH 40/82] refactor(ThemeBrand): Separate into smaller methods --- shiny/ui/_theme_brand.py | 146 ++++++++++++++++++++++----------------- 1 file changed, 84 insertions(+), 62 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index e937bef95..055901aa7 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -189,81 +189,99 @@ def __init__( preset=brand_bootstrap.preset, include_paths=include_paths, ) + self.brand = brand - # brand.color ----------------------------------------------------------------- + # Prep Sass and CSS Variables ------------------------------------------------- + sass_vars_colors, css_vars_colors = self._prepare_color_vars() + sass_vars_typography = self._prepare_typography_vars() + + # 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 Sass color and typography vars + # 2. Brand's Bootstrap Sass vars + # 3. Gray scale variables from Brand fg/bg or black/white + # 4. Fallback vars needed by additional Brand rules + + self._add_sass_ensure_variables() + self._add_sass_brand_grays() + self.add_defaults(**brand_bootstrap.defaults) + self.add_defaults(**sass_vars_colors, **sass_vars_typography) + # Brand Rules ---- + self.add_rules(":root {", *css_vars_colors, "}") + self._add_sass_brand_rules() + + def _prepare_color_vars(self) -> tuple[dict[str, str], list[str]]: + if not self.brand.color: + return {}, [] + sass_vars_colors: dict[str, str] = {} sass_vars_brand_colors: dict[str, str] = {} css_vars_brand_colors: list[str] = [] - if brand.color: - # 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: - self._handle_unmapped_variable(f"color.{thm_name}") - continue + # Map values in colors to their Sass variable counterparts + for thm_name, thm_color in self.brand.color.to_dict(include="theme").items(): + if thm_name not in color_map: + self._handle_unmapped_variable(f"color.{thm_name}") + continue - for sass_var in color_map[thm_name]: - sass_vars_colors[sass_var] = thm_color + for sass_var in color_map[thm_name]: + sass_vars_colors[sass_var] = thm_color - brand_color_palette = brand.color.to_dict(include="palette") + brand_color_palette = self.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: - sass_vars_colors[pal_name] = pal_color + # 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: + sass_vars_colors[pal_name] = pal_color - # Create Sass and CSS variables for the brand color palette - color_var = sanitize_sass_var_name(pal_name) + # Create Sass and CSS variables for the brand color palette + color_var = sanitize_sass_var_name(pal_name) - # => Sass var: `$brand-{name}: {value}` - sass_vars_brand_colors.update({f"brand-{color_var}": pal_color}) - # => CSS var: `--brand-{name}: {value}` - css_vars_brand_colors.append(f"--brand-{color_var}: {pal_color};") + # => Sass var: `$brand-{name}: {value}` + sass_vars_brand_colors.update({f"brand-{color_var}": pal_color}) + # => CSS var: `--brand-{name}: {value}` + css_vars_brand_colors.append(f"--brand-{color_var}: {pal_color};") - # brand.typography ------------------------------------------------------------ + return {**sass_vars_brand_colors, **sass_vars_colors}, css_vars_brand_colors + + def _prepare_typography_vars(self) -> dict[str, str]: sass_vars_typography: dict[str, str] = {} - if brand.typography: - brand_typography = brand.typography.model_dump( - exclude={"fonts"}, - exclude_none=True, - ) - for typ_field, typ_value in brand_typography.items(): - if typ_field not in typography_map: - self._handle_unmapped_variable(f"typography.{typ_field}") - continue - - for typ_field_key, typ_field_value in typ_value.items(): - if typ_field_key in typography_map[typ_field]: - if typ_field == "base" and typ_field_key == "size": - typ_field_value = str( - maybe_convert_font_size_to_rem(typ_field_value) - ) - - typo_sass_vars = typography_map[typ_field][typ_field_key] - for typo_sass_var in typo_sass_vars: - sass_vars_typography[typo_sass_var] = typ_field_value - else: - self._handle_unmapped_variable( - f"typography.{typ_field}.{typ_field_key}" + if not self.brand.typography: + return sass_vars_typography + + brand_typography = self.brand.typography.model_dump( + exclude={"fonts"}, + exclude_none=True, + ) + + for typ_field, typ_value in brand_typography.items(): + if typ_field not in typography_map: + self._handle_unmapped_variable(f"typography.{typ_field}") + continue + + for typ_field_key, typ_field_value in typ_value.items(): + if typ_field_key in typography_map[typ_field]: + if typ_field == "base" and typ_field_key == "size": + typ_field_value = str( + maybe_convert_font_size_to_rem(typ_field_value) ) - # Theme ----------------------------------------------------------------------- - sass_vars_brand: dict[str, str] = { - **sass_vars_brand_colors, - **sass_vars_colors, - **sass_vars_typography, - } - sass_vars_brand = {k: v for k, v in sass_vars_brand.items()} + typo_sass_vars = typography_map[typ_field][typ_field_key] + for typo_sass_var in typo_sass_vars: + sass_vars_typography[typo_sass_var] = typ_field_value + else: + self._handle_unmapped_variable( + f"typography.{typ_field}.{typ_field_key}" + ) - # 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 Sass vars (colors, typography) - # 2. Brand Bootstrap Sass vars - # 3. Fallback vars needed by additional Brand rules + return sass_vars_typography + + def _add_sass_ensure_variables(self): + """Ensure the variables we create to augment Bootstrap's variables exist""" self.add_defaults( - # Variables we create to augment Bootstrap's variables **{ "code-font-weight": None, "code-inline-font-weight": None, @@ -275,6 +293,12 @@ def __init__( "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) { @@ -348,11 +372,9 @@ def __init__( } """ ) - self.add_defaults(**brand_bootstrap.defaults) - self.add_defaults(**sass_vars_brand) - # Brand Rules ---- - self.add_rules(":root {", *css_vars_brand_colors, "}") - # Additional rules to fill in Bootstrap styles for Brand parameters + + def _add_sass_brand_rules(self): + """Additional rules to fill in Bootstrap styles for Brand parameters""" self.add_rules( """ // https://github.com/twbs/bootstrap/blob/5c2f2e7e/scss/_root.scss#L82 From 0fe7fa19fb7eabb185ff11f5347731f9741721d2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 11:42:38 -0400 Subject: [PATCH 41/82] chore(brand): rename methods and add sass comments for dividers --- shiny/ui/_theme_brand.py | 69 +++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 055901aa7..abd5bfa18 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -29,7 +29,7 @@ def __str__(self): # 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", "pre-color", "body-bg-dark"], + "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"], @@ -204,21 +204,31 @@ def __init__( # 3. Gray scale variables from Brand fg/bg or black/white # 4. 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(**brand_bootstrap.defaults) - self.add_defaults(**sass_vars_colors, **sass_vars_typography) - # Brand Rules ---- + self.add_defaults( + "// *---- brand.defaults.bootstrap + brand.defaults.shiny.theme ----* //" + ) + self.add_defaults(**sass_vars_typography) + self.add_defaults("\n// *---- brand.typography ----* //") + self.add_defaults(**sass_vars_colors) + self.add_defaults("\n// *---- brand.color ----* //") + + # Brand rules (now in forwards order) + self.add_rules("\n// *---- brand.color.palette ----*/ /") self.add_rules(":root {", *css_vars_colors, "}") self._add_sass_brand_rules() def _prepare_color_vars(self) -> tuple[dict[str, str], list[str]]: + """Colors: create a dictionary of Sass variables and a list of brand CSS variables""" if not self.brand.color: return {}, [] - sass_vars_colors: dict[str, str] = {} - sass_vars_brand_colors: dict[str, str] = {} - css_vars_brand_colors: list[str] = [] + mapped: dict[str, str] = {} + brand_sass_vars: dict[str, str] = {} + brand_css_vars: list[str] = [] # Map values in colors to their Sass variable counterparts for thm_name, thm_color in self.brand.color.to_dict(include="theme").items(): @@ -227,57 +237,54 @@ def _prepare_color_vars(self) -> tuple[dict[str, str], list[str]]: continue for sass_var in color_map[thm_name]: - sass_vars_colors[sass_var] = thm_color + mapped[sass_var] = thm_color brand_color_palette = self.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: - sass_vars_colors[pal_name] = pal_color + mapped[pal_name] = pal_color # Create Sass and CSS variables for the brand color palette color_var = sanitize_sass_var_name(pal_name) # => Sass var: `$brand-{name}: {value}` - sass_vars_brand_colors.update({f"brand-{color_var}": pal_color}) + brand_sass_vars.update({f"brand-{color_var}": pal_color}) # => CSS var: `--brand-{name}: {value}` - css_vars_brand_colors.append(f"--brand-{color_var}: {pal_color};") + brand_css_vars.append(f"--brand-{color_var}: {pal_color};") - return {**sass_vars_brand_colors, **sass_vars_colors}, css_vars_brand_colors + return {**brand_sass_vars, **mapped}, brand_css_vars def _prepare_typography_vars(self) -> dict[str, str]: - sass_vars_typography: dict[str, str] = {} + """Typography: Create a list of Bootstrap Sass variables""" + mapped: dict[str, str] = {} if not self.brand.typography: - return sass_vars_typography + return mapped brand_typography = self.brand.typography.model_dump( exclude={"fonts"}, exclude_none=True, ) - for typ_field, typ_value in brand_typography.items(): - if typ_field not in typography_map: - self._handle_unmapped_variable(f"typography.{typ_field}") + for field, prop in brand_typography.items(): + if field not in typography_map: + self._handle_unmapped_variable(f"typography.{field}") continue - for typ_field_key, typ_field_value in typ_value.items(): - if typ_field_key in typography_map[typ_field]: - if typ_field == "base" and typ_field_key == "size": - typ_field_value = str( - maybe_convert_font_size_to_rem(typ_field_value) - ) + for prop_key, prop_value in prop.items(): + if prop_key in typography_map[field]: + if field == "base" and prop_key == "size": + prop_value = str(maybe_convert_font_size_to_rem(prop_value)) - typo_sass_vars = typography_map[typ_field][typ_field_key] + typo_sass_vars = typography_map[field][prop_key] for typo_sass_var in typo_sass_vars: - sass_vars_typography[typo_sass_var] = typ_field_value + mapped[typo_sass_var] = prop_value else: - self._handle_unmapped_variable( - f"typography.{typ_field}.{typ_field_key}" - ) + self._handle_unmapped_variable(f"typography.{field}.{prop_key}") - return sass_vars_typography + return mapped def _add_sass_ensure_variables(self): """Ensure the variables we create to augment Bootstrap's variables exist""" @@ -293,6 +300,7 @@ def _add_sass_ensure_variables(self): "link-weight": None, } ) + self.add_defaults("// *---- brand: added variables ---* //") def _add_sass_brand_grays(self): """ @@ -372,11 +380,13 @@ def _add_sass_brand_grays(self): } """ ) + self.add_defaults("// *---- brand: automatic gray gradient ----* //") 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}; @@ -401,7 +411,8 @@ def _add_sass_brand_rules(self): line-height: $code-block-line-height; } - @if variable-exists(brand--background) { + $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"] { From b1d0cccbb2db3b683764bce78656c031c6035a6d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 11:52:27 -0400 Subject: [PATCH 42/82] refactor: move theme method calls into helper methods --- shiny/ui/_theme_brand.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index abd5bfa18..483d99b5d 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -207,18 +207,12 @@ def __init__( self.add_defaults("", "// *---- brand: end of defaults ----* //", "") self._add_sass_ensure_variables() self._add_sass_brand_grays() - self.add_defaults(**brand_bootstrap.defaults) - self.add_defaults( - "// *---- brand.defaults.bootstrap + brand.defaults.shiny.theme ----* //" - ) - self.add_defaults(**sass_vars_typography) - self.add_defaults("\n// *---- brand.typography ----* //") - self.add_defaults(**sass_vars_colors) - self.add_defaults("\n// *---- brand.color ----* //") + self._add_defaults_brand_bootstrap(brand_bootstrap) + self._add_defaults_typography(sass_vars_typography) + self._add_defaults_color(sass_vars_colors) # Brand rules (now in forwards order) - self.add_rules("\n// *---- brand.color.palette ----*/ /") - self.add_rules(":root {", *css_vars_colors, "}") + self._add_rules_brand_colors(css_vars_colors) self._add_sass_brand_rules() def _prepare_color_vars(self) -> tuple[dict[str, str], list[str]]: @@ -382,6 +376,16 @@ def _add_sass_brand_grays(self): ) self.add_defaults("// *---- brand: automatic gray gradient ----* //") + def _add_defaults_brand_bootstrap(self, brand_bootstrap: BrandBootstrap): + self.add_defaults(**brand_bootstrap.defaults) + self.add_defaults( + "// *---- brand.defaults.bootstrap + brand.defaults.shiny.theme ----* //" + ) + + def _add_defaults_typography(self, sass_vars_typography: dict[str, str]): + self.add_defaults(**sass_vars_typography) + self.add_defaults("\n// *---- brand.typography ----* //") + def _add_sass_brand_rules(self): """Additional rules to fill in Bootstrap styles for Brand parameters""" self.add_rules( @@ -422,6 +426,14 @@ def _add_sass_brand_rules(self): """ ) + def _add_defaults_color(self, sass_vars_colors: dict[str, str]): + self.add_defaults(**sass_vars_colors) + self.add_defaults("\n// *---- brand.color ----* //") + + 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 _handle_unmapped_variable(self, unmapped: str): if os.environ.get("SHINY_BRAND_YML_RAISE_UNMAPPED") == "true": raise ThemeBrandUnmappedFieldError(unmapped) From a4eee0337b62862ef0cdc7e16b4ca6ed3c4439fd Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 12:00:25 -0400 Subject: [PATCH 43/82] refactor: factor out get_theme_name --- shiny/ui/_theme_brand.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 483d99b5d..f17e95c49 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -177,10 +177,7 @@ def __init__( include_paths: Optional[str | Path | list[str | Path]] = None, ): - name: str = "brand" - if brand.meta and brand.meta.name: - name = brand.meta.name.full or brand.meta.name.short or name - + name = self._get_theme_name(brand) brand_bootstrap = BrandBootstrap.from_brand(brand) # Initialize theme ------------------------------------------------------------ @@ -215,6 +212,12 @@ def __init__( self._add_rules_brand_colors(css_vars_colors) self._add_sass_brand_rules() + def _get_theme_name(self, brand: Brand) -> str: + if not brand.meta or not brand.meta.name: + return "brand" + + return brand.meta.name.short or brand.meta.name.full or "brand" + def _prepare_color_vars(self) -> tuple[dict[str, str], list[str]]: """Colors: create a dictionary of Sass variables and a list of brand CSS variables""" if not self.brand.color: From ab5085e12c00fdbe2ac8862d8d25ba092017fded Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 12:01:10 -0400 Subject: [PATCH 44/82] refactor: Rename BrandBootstrapConfig --- shiny/ui/_theme_brand.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index f17e95c49..3226af683 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -112,7 +112,7 @@ def __str__(self): """Maps brand.typography fields to corresponding Bootstrap Sass variables""" -class BrandBootstrap: +class BrandBootstrapConfig: """Convenience class for storing Bootstrap defaults from a brand instance""" def __init__( @@ -178,7 +178,7 @@ def __init__( ): name = self._get_theme_name(brand) - brand_bootstrap = BrandBootstrap.from_brand(brand) + brand_bootstrap = BrandBootstrapConfig.from_brand(brand) # Initialize theme ------------------------------------------------------------ super().__init__( @@ -379,7 +379,7 @@ def _add_sass_brand_grays(self): ) self.add_defaults("// *---- brand: automatic gray gradient ----* //") - def _add_defaults_brand_bootstrap(self, brand_bootstrap: BrandBootstrap): + def _add_defaults_brand_bootstrap(self, brand_bootstrap: BrandBootstrapConfig): self.add_defaults(**brand_bootstrap.defaults) self.add_defaults( "// *---- brand.defaults.bootstrap + brand.defaults.shiny.theme ----* //" From 75a7f9771db6525a09d1c93679eb4e8cf90d741e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 13:07:57 -0400 Subject: [PATCH 45/82] feat: read layers from `brand.defaults.shiny.theme.*` --- examples/brand/_brand.yml | 2 + shiny/ui/_theme_brand.py | 145 ++++++++++++++++++++++++++++++++------ 2 files changed, 127 insertions(+), 20 deletions(-) diff --git a/examples/brand/_brand.yml b/examples/brand/_brand.yml index a9c552f93..d8cdcd6ac 100644 --- a/examples/brand/_brand.yml +++ b/examples/brand/_brand.yml @@ -106,5 +106,7 @@ defaults: shiny: theme: preset: shiny + rules: | + .navbar-brand { color: $brand-pink } # TODO: Find an appropriate theme variable to set # navbar-bg: $brand-purple diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 3226af683..03919a284 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -4,16 +4,18 @@ import re import warnings from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, Union from brand_yml import Brand from htmltools import HTMLDependency -from .._versions import bootstrap +from .._versions import bootstrap as v_bootstrap from ._theme import Theme from ._theme_presets import ShinyThemePreset, shiny_theme_presets from .css import CssUnit, as_css_unit +YamlScalarType = Union[str, int, bool, float, None] + class ThemeBrandUnmappedFieldError(ValueError): def __init__(self, field: str): @@ -112,14 +114,72 @@ def __str__(self): """Maps brand.typography fields to corresponding Bootstrap Sass variables""" +class BrandBootstrapConfigFromYaml: + def __init__( + self, + path: str, + version: Any = None, + preset: Any = None, + functions: Any = None, + defaults: Any = None, + mixins: Any = None, + rules: Any = None, + ): + + self.path = path + self.version = version + self.preset: str | None = self._validate_str(preset, "preset") + self.functions: str | None = self._validate_str(functions, "functions") + self.defaults: dict[str, YamlScalarType] | None = self._validate_defaults( + defaults + ) + self.mixins: str | None = self._validate_str(mixins, "mixins") + self.rules: str | None = self._validate_str(rules, "rules") + + def _validate_str(self, x: Any, param: str) -> str | None: + if x is None or isinstance(x, str): + return x + + raise ValueError( + f"Invalid brand `{self.path}.{param}`. Must be a string or empty." + ) + + def _validate_defaults(self, x: Any) -> dict[str, YamlScalarType] | None: + if x is None: + return None + + path = self.path + if path == "defaults.shiny.theme": + path += ".defaults" + + if not isinstance(x, dict): + raise ValueError(f"Invalid brand `{path}`, must be a dictionary.") + + y: dict[Any, Any] = x + + if not all([isinstance(k, str) for k in y.keys()]): + raise ValueError(f"Invalid brand `{path}`, all keys must be strings.") + + if not all( + [v is None or isinstance(v, (str, int, float, bool)) for v in y.values()] + ): + raise ValueError(f"Invalid brand `{path}`, all values must be scalar.") + + res: dict[str, YamlScalarType] = y + return res + + class BrandBootstrapConfig: """Convenience class for storing Bootstrap defaults from a brand instance""" def __init__( self, - version: Any = bootstrap, + version: Any = v_bootstrap, preset: Any = "shiny", - **kwargs: str | int | bool | float | None, + functions: str | None = None, + defaults: dict[str, YamlScalarType] | None = None, + mixins: str | None = None, + rules: str | None = None, ): if not isinstance(version, (str, int)): raise ValueError( @@ -127,7 +187,7 @@ def __init__( ) v_major = str(version).split(".")[0] - bs_major = str(bootstrap).split(".")[0] + bs_major = str(v_bootstrap).split(".")[0] if v_major != bs_major: # TODO (bootstrap-update): Assumes Shiny ships one version of Bootstrap @@ -146,27 +206,63 @@ def __init__( self.version = v_major self.preset: ShinyThemePreset = preset - self.defaults = kwargs + self.functions = functions + self.defaults = defaults + self.mixins = mixins + self.rules = rules @classmethod def from_brand(cls, brand: Brand): - defaults: dict[str, str | int | bool | float | None] = {} + if not brand.defaults: + return cls(version=v_bootstrap, preset="shiny") - if brand.defaults: - if brand.defaults and "bootstrap" in brand.defaults: - if isinstance(brand.defaults["bootstrap"], dict): - brand_defaults_bs: dict[str, str] = brand.defaults["bootstrap"] - defaults.update(brand_defaults_bs) - if "shiny" in brand.defaults and "theme" in brand.defaults["shiny"]: - if isinstance(brand.defaults["shiny"]["theme"], dict): - # TODO: Use brand.defaults.shiny.theme.defaults instead - # TODO: Validate that it's really a dict[str, scalar] - brand_shiny_theme: dict[str, str] = brand.defaults["shiny"]["theme"] - defaults.update(brand_shiny_theme) + defaults: dict[str, YamlScalarType] = {} - # TODO: Get functions, mixins, rules as well + d_bootstrap = cls._brand_defaults_bootstrap(brand) + d_shiny = cls._brand_defaults_shiny(brand) - return cls(**defaults) + defaults.update(d_bootstrap.defaults or {}) + defaults.update(d_shiny.defaults or {}) + + return cls( + version=d_shiny.version or d_bootstrap.version or v_bootstrap, + preset=d_shiny.preset or d_bootstrap.preset or "shiny", + functions=d_shiny.functions, + defaults=defaults, + mixins=d_shiny.mixins, + rules=d_shiny.rules, + ) + + @classmethod + def _brand_defaults_shiny(cls, brand: Brand) -> BrandBootstrapConfigFromYaml: + if ( + not brand.defaults + or not isinstance(brand.defaults.get("shiny"), dict) + or not isinstance(brand.defaults["shiny"].get("theme"), dict) + ): + return BrandBootstrapConfigFromYaml(path="defaults.shiny.theme") + + return BrandBootstrapConfigFromYaml( + path="defaults.shiny.theme", + **brand.defaults["shiny"]["theme"], + ) + + @classmethod + def _brand_defaults_bootstrap(cls, brand: Brand) -> BrandBootstrapConfigFromYaml: + if not brand.defaults or not isinstance(brand.defaults.get("bootstrap"), dict): + return BrandBootstrapConfigFromYaml(path="defaults.bootstrap") + + bootstrap: dict[str, Any] = brand.defaults["bootstrap"] + defaults: dict[str, Any] = { + k: v for k, v in bootstrap if k not in ("version", "preset") + } + + return BrandBootstrapConfigFromYaml( + path="defaults.bootstrap", + version=bootstrap.get("version"), + preset=bootstrap.get("preset"), + **defaults, + ) class ThemeBrand(Theme): @@ -211,6 +307,7 @@ def __init__( # Brand rules (now in forwards order) self._add_rules_brand_colors(css_vars_colors) self._add_sass_brand_rules() + self._add_brand_bootstrap_other(brand_bootstrap) def _get_theme_name(self, brand: Brand) -> str: if not brand.meta or not brand.meta.name: @@ -437,6 +534,14 @@ 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) + if bootstrap.mixins: + self.add_mixins(bootstrap.mixins) + if bootstrap.rules: + self.add_rules(bootstrap.rules) + def _handle_unmapped_variable(self, unmapped: str): if os.environ.get("SHINY_BRAND_YML_RAISE_UNMAPPED") == "true": raise ThemeBrandUnmappedFieldError(unmapped) From 438605f072407634dc4eaddf374063dc421f950e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 13:10:15 -0400 Subject: [PATCH 46/82] chore: Add `!default` flag --- shiny/ui/_theme_brand.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 03919a284..1fc791675 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -450,26 +450,26 @@ def _add_sass_brand_grays(self): @if $white == null { $brand-white: map-get($brand-white-black, "white"); @if $brand-white != null { - $white: $brand-white; + $white: $brand-white !default; } } @if $black == null { $brand-black: map-get($brand-white-black, "black"); @if $brand-black != null { - $black: $brand-black; + $black: $brand-black !default; } } } @if $white != null and $black != null { - $gray-100: mix($white, $black, 90%); - $gray-200: mix($white, $black, 80%); - $gray-300: mix($white, $black, 70%); - $gray-400: mix($white, $black, 60%); - $gray-500: mix($white, $black, 50%); - $gray-600: mix($white, $black, 40%); - $gray-700: mix($white, $black, 30%); - $gray-800: mix($white, $black, 20%); - $gray-900: mix($white, $black, 10%); + $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; } } """ From f08351976add1e4fce16b813c3039de9934f9deb Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 13:11:18 -0400 Subject: [PATCH 47/82] chore: early return in no-op case --- shiny/ui/_theme_brand.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 1fc791675..4e548d5b0 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -477,6 +477,9 @@ def _add_sass_brand_grays(self): self.add_defaults("// *---- brand: automatic gray gradient ----* //") def _add_defaults_brand_bootstrap(self, brand_bootstrap: BrandBootstrapConfig): + if not brand_bootstrap.defaults: + return + self.add_defaults(**brand_bootstrap.defaults) self.add_defaults( "// *---- brand.defaults.bootstrap + brand.defaults.shiny.theme ----* //" From dbc475960a08f28d8861ed8eb48660a5ecab5246 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 13:17:07 -0400 Subject: [PATCH 48/82] refactor: Static method for brand to sass variable helpers --- shiny/ui/_theme_brand.py | 45 +++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 4e548d5b0..ff1959d93 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -27,6 +27,16 @@ 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 @@ -286,8 +296,8 @@ def __init__( self.brand = brand # Prep Sass and CSS Variables ------------------------------------------------- - sass_vars_colors, css_vars_colors = self._prepare_color_vars() - sass_vars_typography = self._prepare_typography_vars() + sass_vars_colors, css_vars_colors = 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 @@ -315,9 +325,10 @@ def _get_theme_name(self, brand: Brand) -> str: return brand.meta.name.short or brand.meta.name.full or "brand" - def _prepare_color_vars(self) -> tuple[dict[str, str], list[str]]: + @staticmethod + def _prepare_color_vars(brand: Brand) -> tuple[dict[str, str], list[str]]: """Colors: create a dictionary of Sass variables and a list of brand CSS variables""" - if not self.brand.color: + if not brand.color: return {}, [] mapped: dict[str, str] = {} @@ -325,15 +336,15 @@ def _prepare_color_vars(self) -> tuple[dict[str, str], list[str]]: brand_css_vars: list[str] = [] # Map values in colors to their Sass variable counterparts - for thm_name, thm_color in self.brand.color.to_dict(include="theme").items(): + for thm_name, thm_color in brand.color.to_dict(include="theme").items(): if thm_name not in color_map: - self._handle_unmapped_variable(f"color.{thm_name}") + warn_or_raise_unmapped_variable(f"color.{thm_name}") continue for sass_var in color_map[thm_name]: mapped[sass_var] = thm_color - brand_color_palette = self.brand.color.to_dict(include="palette") + 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(): @@ -350,21 +361,22 @@ def _prepare_color_vars(self) -> tuple[dict[str, str], list[str]]: return {**brand_sass_vars, **mapped}, brand_css_vars - def _prepare_typography_vars(self) -> dict[str, str]: + @staticmethod + def _prepare_typography_vars(brand: Brand) -> dict[str, str]: """Typography: Create a list of Bootstrap Sass variables""" mapped: dict[str, str] = {} - if not self.brand.typography: + if not brand.typography: return mapped - brand_typography = self.brand.typography.model_dump( + brand_typography = brand.typography.model_dump( exclude={"fonts"}, exclude_none=True, ) for field, prop in brand_typography.items(): if field not in typography_map: - self._handle_unmapped_variable(f"typography.{field}") + warn_or_raise_unmapped_variable(f"typography.{field}") continue for prop_key, prop_value in prop.items(): @@ -376,7 +388,7 @@ def _prepare_typography_vars(self) -> dict[str, str]: for typo_sass_var in typo_sass_vars: mapped[typo_sass_var] = prop_value else: - self._handle_unmapped_variable(f"typography.{field}.{prop_key}") + warn_or_raise_unmapped_variable(f"typography.{field}.{prop_key}") return mapped @@ -545,15 +557,6 @@ def _add_brand_bootstrap_other(self, bootstrap: BrandBootstrapConfig): if bootstrap.rules: self.add_rules(bootstrap.rules) - def _handle_unmapped_variable(self, 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, - ) - def _html_dependencies(self) -> list[HTMLDependency]: theme_deps = super()._html_dependencies() From 6e57945cbb61387f034eaa88454b7772bea4e586 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 13:23:16 -0400 Subject: [PATCH 49/82] fix: brand-yml inventory objects location --- docs/_quarto.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 4aa2c6d63..b656d926e 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -46,3 +46,4 @@ interlinks: url: https://docs.python.org/3/ brand-yml: url: https://posit-dev.github.io/brand-yml/ + inv: objects.txt From 1c2a50400b68d6b90bd8e3ac941c7ce46240a899 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 14:22:53 -0400 Subject: [PATCH 50/82] feat(typography): Map inline code color to $code-color-dark --- 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 ff1959d93..a1ed74d0b 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -101,7 +101,7 @@ def warn_or_raise_unmapped_variable(unmapped: str): }, "monospace_inline": { "family": ["font-family-monospace-inline"], - "color": ["code-color"], + "color": ["code-color", "code-color-dark"], "background_color": ["code-bg"], "size": ["code-inline-font-size"], "weight": ["code-inline-font-weight"], From 9591fb2b910e70877b62b949d34e02afb3baa26d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 14:27:45 -0400 Subject: [PATCH 51/82] example(brand): Keep navbar visible --- examples/brand/app.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/examples/brand/app.py b/examples/brand/app.py index 63b97693a..59732b58c 100644 --- a/examples/brand/app.py +++ b/examples/brand/app.py @@ -125,12 +125,22 @@ heights_equal=False, ), ), - ui.nav_panel("Colors", ui.div(ui.output_ui("ui_colors"), class_="container-sm")), + ui.nav_panel( + "Colors", + ui.fill.as_fill_item( + ui.div( + ui.div(ui.output_ui("ui_colors"), class_="container-sm"), + class_="overflow-y-auto", + ) + ), + ), ui.nav_panel( "Documentation", - ui.div( - ui.markdown( - """ + ui.fill.as_fill_item( + ui.div( + ui.div( + ui.markdown( + """ _Just in case it isn't obvious, this text was written by an LLM._ # Component Documentation @@ -223,14 +233,17 @@ remaining flexible enough to accommodate future updates and modifications to the application interface. """ - ), - class_="container-sm", + ), + class_="container-sm ", + ), + class_="overflow-y-auto", + ) ), ), ui.nav_spacer(), ui.nav_control(ui.input_dark_mode(id="color_mode")), title="brand.yml Demo", - fillable=["Input Output Demo", "Widget Gallery"], + fillable=True, theme=theme, ) From 721b1be60626c18ba8545def5a264a38b3a6e91c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 15:12:59 -0400 Subject: [PATCH 52/82] feat: check that brand_yml is installed before using --- shiny/ui/_theme.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index 3053aaa36..cbc8fcd12 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -5,9 +5,10 @@ import re import tempfile import textwrap -from typing import Any, Literal, Optional, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, Literal, Optional, Sequence, TypeVar -from brand_yml import Brand +if TYPE_CHECKING: + from brand_yml import Brand from htmltools import HTMLDependency from .._docstring import add_example @@ -410,7 +411,7 @@ def to_css( self._css = self._read_precompiled_css() return self._css - check_libsass_installed() + check_theme_pkg_installed("libsass", "sass") import sass args: SassCompileArgs = {} if compile_args is None else compile_args @@ -525,6 +526,10 @@ def from_brand(cls, brand: str | pathlib.Path | Brand): A :class:`shiny.ui.Theme` instance with a custom Shiny theme created from the brand guidelines (see :class:`brand_yml.Brand`). """ + check_theme_pkg_installed("brand_yml") + + from brand_yml import Brand + from ._theme_brand import ThemeBrand # avoid circular import if not isinstance(brand, Brand): @@ -559,13 +564,16 @@ def check_is_valid_preset(preset: ShinyThemePreset) -> None: ) -def check_libsass_installed() -> None: +def check_theme_pkg_installed(pkg: str, spec: str | None = None) -> None: import importlib.util - if importlib.util.find_spec("sass") is None: + if spec is None: + spec = pkg + + if importlib.util.find_spec(spec) is None: raise ImportError( - "The 'libsass' package is required to compile custom themes. " - 'Please install it with `pip install libsass` or `pip install "shiny[theme]"`.', + f"The '{pkg}' package is required to compile custom themes. " + 'Please install it with `pip install {pkg}` or `pip install "shiny[theme]"`.', ) From c314806f23ed4c00b0e384cfaac1fa48907f3b90 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 15:14:47 -0400 Subject: [PATCH 53/82] refactor(BrandBootstrapConfig): Make static methods --- shiny/ui/_theme_brand.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index a1ed74d0b..860831b4e 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -228,8 +228,8 @@ def from_brand(cls, brand: Brand): defaults: dict[str, YamlScalarType] = {} - d_bootstrap = cls._brand_defaults_bootstrap(brand) - d_shiny = cls._brand_defaults_shiny(brand) + d_bootstrap = BrandBootstrapConfig._brand_defaults_bootstrap(brand) + d_shiny = BrandBootstrapConfig._brand_defaults_shiny(brand) defaults.update(d_bootstrap.defaults or {}) defaults.update(d_shiny.defaults or {}) @@ -243,8 +243,8 @@ def from_brand(cls, brand: Brand): rules=d_shiny.rules, ) - @classmethod - def _brand_defaults_shiny(cls, brand: Brand) -> BrandBootstrapConfigFromYaml: + @staticmethod + def _brand_defaults_shiny(brand: Brand) -> BrandBootstrapConfigFromYaml: if ( not brand.defaults or not isinstance(brand.defaults.get("shiny"), dict) @@ -257,8 +257,8 @@ def _brand_defaults_shiny(cls, brand: Brand) -> BrandBootstrapConfigFromYaml: **brand.defaults["shiny"]["theme"], ) - @classmethod - def _brand_defaults_bootstrap(cls, brand: Brand) -> BrandBootstrapConfigFromYaml: + @staticmethod + def _brand_defaults_bootstrap(brand: Brand) -> BrandBootstrapConfigFromYaml: if not brand.defaults or not isinstance(brand.defaults.get("bootstrap"), dict): return BrandBootstrapConfigFromYaml(path="defaults.bootstrap") From cf103d81cc3896adbc2b9af4e5f23582e49a1d89 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 15:57:21 -0400 Subject: [PATCH 54/82] refactor: return brand/bootstrap color sass vars separately --- shiny/ui/_theme_brand.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 860831b4e..5c20b5851 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -296,7 +296,9 @@ def __init__( self.brand = brand # Prep Sass and CSS Variables ------------------------------------------------- - sass_vars_colors, css_vars_colors = ThemeBrand._prepare_color_vars(brand) + sass_vars_colors, sass_vars_brand, css_vars_brand = ( + ThemeBrand._prepare_color_vars(brand) + ) sass_vars_typography = ThemeBrand._prepare_typography_vars(brand) # Theme ----------------------------------------------------------------------- @@ -312,10 +314,10 @@ def __init__( self._add_sass_brand_grays() self._add_defaults_brand_bootstrap(brand_bootstrap) self._add_defaults_typography(sass_vars_typography) - self._add_defaults_color(sass_vars_colors) + self._add_defaults_color(sass_vars_colors, sass_vars_brand) # Brand rules (now in forwards order) - self._add_rules_brand_colors(css_vars_colors) + self._add_rules_brand_colors(css_vars_brand) self._add_sass_brand_rules() self._add_brand_bootstrap_other(brand_bootstrap) @@ -326,7 +328,9 @@ def _get_theme_name(self, brand: Brand) -> str: return brand.meta.name.short or brand.meta.name.full or "brand" @staticmethod - def _prepare_color_vars(brand: Brand) -> tuple[dict[str, str], list[str]]: + 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""" if not brand.color: return {}, [] @@ -359,7 +363,9 @@ def _prepare_color_vars(brand: Brand) -> tuple[dict[str, str], list[str]]: # => CSS var: `--brand-{name}: {value}` brand_css_vars.append(f"--brand-{color_var}: {pal_color};") - return {**brand_sass_vars, **mapped}, brand_css_vars + # 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 @staticmethod def _prepare_typography_vars(brand: Brand) -> dict[str, str]: @@ -541,8 +547,13 @@ def _add_sass_brand_rules(self): """ ) - def _add_defaults_color(self, sass_vars_colors: dict[str, str]): + def _add_defaults_color( + self, + sass_vars_colors: dict[str, str], + sass_vars_brand: dict[str, str], + ): self.add_defaults(**sass_vars_colors) + self.add_defaults(**sass_vars_brand) self.add_defaults("\n// *---- brand.color ----* //") def _add_rules_brand_colors(self, css_vars_colors: list[str]): From 82b449347f33b18e860aa464948f6e23c5ca3d2f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 15:57:47 -0400 Subject: [PATCH 55/82] chore: set black/white to brand black white if not set --- shiny/ui/_theme_brand.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 5c20b5851..b62c400e0 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -466,16 +466,10 @@ def _add_sass_brand_grays(self): @if variable-exists(brand--foreground) and variable-exists(brand--background) { $brand-white-black: brand-choose-white-black($brand--foreground, $brand--background); @if $white == null { - $brand-white: map-get($brand-white-black, "white"); - @if $brand-white != null { - $white: $brand-white !default; - } + $white: map-get($brand-white-black, "white") !default; } @if $black == null { - $brand-black: map-get($brand-white-black, "black"); - @if $brand-black != null { - $black: $brand-black !default; - } + $black: map-get($brand-white-black, "black") !default; } } @if $white != null and $black != null { From 59b93c9985d56c429fed8f8bf938555a1a5f138a Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 16:13:00 -0400 Subject: [PATCH 56/82] refactor: simplify return value --- shiny/ui/_theme_brand.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index b62c400e0..a189064fe 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -436,12 +436,10 @@ def _add_sass_brand_grays(self): // 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. - $result: ( + @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%)), ); - - @return $result; } """ ) From 9e8211c8ca4347c5bd74e479aad84360c4a032d0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 16:29:22 -0400 Subject: [PATCH 57/82] chore(types): Fix return value --- 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 a189064fe..0aa68960d 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -333,7 +333,7 @@ def _prepare_color_vars( ) -> tuple[dict[str, str], dict[str, str], list[str]]: """Colors: create a dictionary of Sass variables and a list of brand CSS variables""" if not brand.color: - return {}, [] + return {}, {}, [] mapped: dict[str, str] = {} brand_sass_vars: dict[str, str] = {} From 22bd9bde67d4e434a23cf00632563d52cc07883c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 17:11:41 -0400 Subject: [PATCH 58/82] refactor: Move preset checking into `ui.Theme()` --- shiny/ui/_theme.py | 9 +++++---- shiny/ui/_theme_brand.py | 13 +++---------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index cbc8fcd12..f5240fa2b 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -131,12 +131,11 @@ def server(input): def __init__( self, - preset: ShinyThemePreset = "shiny", + preset: str | None = None, name: Optional[str] = None, include_paths: Optional[str | pathlib.Path | list[str | pathlib.Path]] = None, ): - check_is_valid_preset(preset) - self._preset: ShinyThemePreset = preset + self._preset: ShinyThemePreset = check_is_valid_preset(preset or "shiny") self.name = name # 2024-06-21: `version` is not exposed because we currently support only BS 5. # In the future, the Bootstrap version could be chosen by the user on init. @@ -556,13 +555,15 @@ def path_pkg_preset(preset: ShinyThemePreset, *args: str) -> str: return pathlib.Path(path).as_posix() -def check_is_valid_preset(preset: ShinyThemePreset) -> None: +def check_is_valid_preset(preset: str | None) -> ShinyThemePreset: if preset not in shiny_theme_presets: raise ValueError( f"Invalid preset '{preset}'.\n" + f"""Expected one of: "{'", "'.join(shiny_theme_presets)}".""", ) + return preset + def check_theme_pkg_installed(pkg: str, spec: str | None = None) -> None: import importlib.util diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 0aa68960d..05ba2fb16 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -11,7 +11,6 @@ from .._versions import bootstrap as v_bootstrap from ._theme import Theme -from ._theme_presets import ShinyThemePreset, shiny_theme_presets from .css import CssUnit, as_css_unit YamlScalarType = Union[str, int, bool, float, None] @@ -185,7 +184,7 @@ class BrandBootstrapConfig: def __init__( self, version: Any = v_bootstrap, - preset: Any = "shiny", + preset: str | None = None, functions: str | None = None, defaults: dict[str, YamlScalarType] | None = None, mixins: str | None = None, @@ -208,14 +207,8 @@ def __init__( ) v_major = bs_major - if not isinstance(preset, str) or preset not in shiny_theme_presets: - raise ValueError( - f"{preset!r} is not a valid Bootstrap preset provided by Shiny. " - f"Valid presets are {shiny_theme_presets}." - ) - self.version = v_major - self.preset: ShinyThemePreset = preset + self.preset = preset self.functions = functions self.defaults = defaults self.mixins = mixins @@ -236,7 +229,7 @@ def from_brand(cls, brand: Brand): return cls( version=d_shiny.version or d_bootstrap.version or v_bootstrap, - preset=d_shiny.preset or d_bootstrap.preset or "shiny", + preset=d_shiny.preset or d_bootstrap.preset, functions=d_shiny.functions, defaults=defaults, mixins=d_shiny.mixins, From 1242c6deca6fb62962d4eeccba13bc2d86ee7e6e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 17:12:14 -0400 Subject: [PATCH 59/82] fix: defaults from `defaults.bootstrap` --- examples/brand/_brand.yml | 4 +++- shiny/ui/_theme_brand.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/brand/_brand.yml b/examples/brand/_brand.yml index d8cdcd6ac..b840bc985 100644 --- a/examples/brand/_brand.yml +++ b/examples/brand/_brand.yml @@ -103,10 +103,12 @@ typography: decoration: "underline" defaults: + bootstrap: + my-pink: "$brand-pink" shiny: theme: preset: shiny rules: | - .navbar-brand { color: $brand-pink } + .navbar-brand { color: $my-pink } # TODO: Find an appropriate theme variable to set # navbar-bg: $brand-purple diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 05ba2fb16..a0ee1b101 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -257,14 +257,14 @@ def _brand_defaults_bootstrap(brand: Brand) -> BrandBootstrapConfigFromYaml: bootstrap: dict[str, Any] = brand.defaults["bootstrap"] defaults: dict[str, Any] = { - k: v for k, v in bootstrap if k not in ("version", "preset") + k: v for k, v in bootstrap.items() if k not in ("version", "preset") } return BrandBootstrapConfigFromYaml( path="defaults.bootstrap", version=bootstrap.get("version"), preset=bootstrap.get("preset"), - **defaults, + defaults=defaults, ) From 9bebe72a15a735d386059288259dd6a917427367 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 17:13:07 -0400 Subject: [PATCH 60/82] fix: rework layer adding to ensure correct order Bootstrap defaults come after brand palette but before brand vars --- shiny/ui/_theme_brand.py | 48 ++++++++++++++-------------------------- 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index a0ee1b101..2ec75bd85 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -289,7 +289,7 @@ def __init__( self.brand = brand # Prep Sass and CSS Variables ------------------------------------------------- - sass_vars_colors, sass_vars_brand, css_vars_brand = ( + sass_vars_theme_colors, sass_vars_brand_colors, css_vars_brand = ( ThemeBrand._prepare_color_vars(brand) ) sass_vars_typography = ThemeBrand._prepare_typography_vars(brand) @@ -297,17 +297,21 @@ def __init__( # 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 Sass color and typography vars - # 2. Brand's Bootstrap Sass vars - # 3. Gray scale variables from Brand fg/bg or black/white - # 4. Fallback vars needed by additional Brand rules + # 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_brand_bootstrap(brand_bootstrap) - self._add_defaults_typography(sass_vars_typography) - self._add_defaults_color(sass_vars_colors, sass_vars_brand) + 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) @@ -391,6 +395,10 @@ def _prepare_typography_vars(brand: Brand) -> dict[str, str]: 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( @@ -438,6 +446,7 @@ def _add_sass_brand_grays(self): ) 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; @@ -477,20 +486,6 @@ def _add_sass_brand_grays(self): } """ ) - self.add_defaults("// *---- brand: automatic gray gradient ----* //") - - def _add_defaults_brand_bootstrap(self, brand_bootstrap: BrandBootstrapConfig): - if not brand_bootstrap.defaults: - return - - self.add_defaults(**brand_bootstrap.defaults) - self.add_defaults( - "// *---- brand.defaults.bootstrap + brand.defaults.shiny.theme ----* //" - ) - - def _add_defaults_typography(self, sass_vars_typography: dict[str, str]): - self.add_defaults(**sass_vars_typography) - self.add_defaults("\n// *---- brand.typography ----* //") def _add_sass_brand_rules(self): """Additional rules to fill in Bootstrap styles for Brand parameters""" @@ -532,15 +527,6 @@ def _add_sass_brand_rules(self): """ ) - def _add_defaults_color( - self, - sass_vars_colors: dict[str, str], - sass_vars_brand: dict[str, str], - ): - self.add_defaults(**sass_vars_colors) - self.add_defaults(**sass_vars_brand) - self.add_defaults("\n// *---- brand.color ----* //") - 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, "}") From 0d72daa99521917e8d14f4fac06d2147fbf10620 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 17:54:34 -0400 Subject: [PATCH 61/82] chore: update type hint Co-authored-by: Carson Sievert --- shiny/ui/_theme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index f5240fa2b..a85872126 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -503,7 +503,7 @@ def tagify(self) -> None: ) @classmethod - def from_brand(cls, brand: str | pathlib.Path | Brand): + def from_brand(cls, brand: "str | pathlib.Path | Brand"): """ Create a custom Shiny theme from a `_brand.yml` From fa78936adecd403f01eb1c9d0dd9ce113da5ca73 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 22:53:33 -0400 Subject: [PATCH 62/82] temp: switch to brand_yml from github --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bcb936051..950e99821 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ [project.optional-dependencies] theme = [ "libsass>=0.23.0", - "brand_yml>=0.1.0rc6" + "brand_yml@git+https://github.com/posit-dev/brand-yml" ] test = [ "pytest>=6.2.4", From f7f56057a757d78c1354d4f5d499e029b5536cc4 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 23:02:38 -0400 Subject: [PATCH 63/82] refactor: brand_yml now handles validation of `color.palette` names --- shiny/ui/_theme_brand.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 2ec75bd85..105b67b88 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -353,12 +353,10 @@ def _prepare_color_vars( mapped[pal_name] = pal_color # Create Sass and CSS variables for the brand color palette - color_var = sanitize_sass_var_name(pal_name) - # => Sass var: `$brand-{name}: {value}` - brand_sass_vars.update({f"brand-{color_var}": pal_color}) + brand_sass_vars.update({f"brand-{pal_name}": pal_color}) # => CSS var: `--brand-{name}: {value}` - brand_css_vars.append(f"--brand-{color_var}: {pal_color};") + 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. @@ -565,11 +563,6 @@ def _html_dependencies(self) -> list[HTMLDependency]: return [fonts_dep, *theme_deps] -def sanitize_sass_var_name(x: str) -> str: - x = re.sub(r"""['"]""", "", x) - return re.sub(r"[^a-zA-Z0-9_-]+", "-", x) - - def maybe_convert_font_size_to_rem(x: str) -> CssUnit: """ Convert a font size to rem From 16db15de55720cce1991733137c2bc822a4b6725 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 23:03:54 -0400 Subject: [PATCH 64/82] chore: use `_add_defaults_hdr()` in additional place --- shiny/ui/_theme_brand.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 105b67b88..05972e7f9 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -399,7 +399,8 @@ def _add_defaults_hdr(self, header: str, **kwargs: YamlScalarType): def _add_sass_ensure_variables(self): """Ensure the variables we create to augment Bootstrap's variables exist""" - self.add_defaults( + self._add_defaults_hdr( + "added variables", **{ "code-font-weight": None, "code-inline-font-weight": None, @@ -409,9 +410,8 @@ def _add_sass_ensure_variables(self): "code-block-line-height": None, "link-bg": None, "link-weight": None, - } + }, ) - self.add_defaults("// *---- brand: added variables ---* //") def _add_sass_brand_grays(self): """ From af8532af3a4d8061654a67a3497c3bff89cd2c40 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Thu, 24 Oct 2024 23:05:29 -0400 Subject: [PATCH 65/82] refactor: don't use `as_css_unit()` just work with strings --- shiny/ui/_theme_brand.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 05972e7f9..bb76892ea 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -11,7 +11,6 @@ from .._versions import bootstrap as v_bootstrap from ._theme import Theme -from .css import CssUnit, as_css_unit YamlScalarType = Union[str, int, bool, float, None] @@ -383,7 +382,7 @@ def _prepare_typography_vars(brand: Brand) -> dict[str, str]: for prop_key, prop_value in prop.items(): if prop_key in typography_map[field]: if field == "base" and prop_key == "size": - prop_value = str(maybe_convert_font_size_to_rem(prop_value)) + prop_value = maybe_convert_font_size_to_rem(prop_value) typo_sass_vars = typography_map[field][prop_key] for typo_sass_var in typo_sass_vars: @@ -563,7 +562,7 @@ def _html_dependencies(self) -> list[HTMLDependency]: return [fonts_dep, *theme_deps] -def maybe_convert_font_size_to_rem(x: str) -> CssUnit: +def maybe_convert_font_size_to_rem(x: str) -> str: """ Convert a font size to rem @@ -579,7 +578,6 @@ def maybe_convert_font_size_to_rem(x: str) -> CssUnit: 7. `42.3mm` is `1rem`. """ x_og = f"{x}" - x = as_css_unit(x) value, unit = split_css_value_and_unit(x) @@ -587,7 +585,7 @@ def maybe_convert_font_size_to_rem(x: str) -> CssUnit: return x if unit == "em": - return as_css_unit(f"{value}rem") + return f"{value}rem" scale = { "%": 100, @@ -599,7 +597,7 @@ def maybe_convert_font_size_to_rem(x: str) -> CssUnit: } if unit in scale: - return as_css_unit(f"{float(value) / scale[unit]}rem") + return f"{float(value) / scale[unit]}rem" raise ValueError( f"Shiny does not support brand.yml font sizes in {unit} units ({x_og!r})" From 04b301161f5bf051d2c936629bf2abf0506549ce Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 08:02:18 -0400 Subject: [PATCH 66/82] refactor: Call `cls` from inside class method --- shiny/ui/_theme_brand.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index bb76892ea..6ea15d54f 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -220,8 +220,8 @@ def from_brand(cls, brand: Brand): defaults: dict[str, YamlScalarType] = {} - d_bootstrap = BrandBootstrapConfig._brand_defaults_bootstrap(brand) - d_shiny = BrandBootstrapConfig._brand_defaults_shiny(brand) + d_bootstrap = cls._brand_defaults_bootstrap(brand) + d_shiny = cls._brand_defaults_shiny(brand) defaults.update(d_bootstrap.defaults or {}) defaults.update(d_shiny.defaults or {}) From d030f8ddf87ed05e7256fccd7b42ff61fca83a27 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 08:03:17 -0400 Subject: [PATCH 67/82] chore(reload): On `yml` and `yaml` changes --- shiny/_main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shiny/_main.py b/shiny/_main.py index 0c13784c6..2f7ac632e 100644 --- a/shiny/_main.py +++ b/shiny/_main.py @@ -41,8 +41,8 @@ def main() -> None: "*.htm", "*.html", "*.png", - "*brand*.yml", - "*brand*.yaml", + "*.yml", + "*.yaml", ) RELOAD_EXCLUDES_DEFAULT = (".*", "*.py[cod]", "__pycache__", "env", "venv") From 90b03f5dc396aade9092c069df1c0cfc0cd56d36 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 09:01:15 -0400 Subject: [PATCH 68/82] refactor: brand_yml handles converting typography.base.size to rem --- examples/brand/_brand.yml | 2 +- shiny/ui/_theme_brand.py | 57 ++------------------------------------- 2 files changed, 3 insertions(+), 56 deletions(-) diff --git a/examples/brand/_brand.yml b/examples/brand/_brand.yml index b840bc985..dbdba16f1 100644 --- a/examples/brand/_brand.yml +++ b/examples/brand/_brand.yml @@ -71,7 +71,7 @@ typography: display: swap base: family: Monda - size: 1em + size: 17px weight: 400 line-height: 1.5 headings: diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 6ea15d54f..ea12360e1 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import re import warnings from pathlib import Path from typing import Any, Optional, Union @@ -81,7 +80,7 @@ def warn_or_raise_unmapped_variable(unmapped: str): typography_map: dict[str, dict[str, list[str]]] = { "base": { "family": ["font-family-base"], - "size": ["font-size-base"], + "size": ["font-size-base"], # TODO: consider using $font-size-root instead "line_height": ["line-height-base"], "weight": ["font-weight-base"], }, @@ -372,6 +371,7 @@ def _prepare_typography_vars(brand: Brand) -> dict[str, str]: brand_typography = brand.typography.model_dump( exclude={"fonts"}, exclude_none=True, + context={"typography_base_size_unit": "rem"}, ) for field, prop in brand_typography.items(): @@ -381,9 +381,6 @@ def _prepare_typography_vars(brand: Brand) -> dict[str, str]: for prop_key, prop_value in prop.items(): if prop_key in typography_map[field]: - if field == "base" and prop_key == "size": - prop_value = maybe_convert_font_size_to_rem(prop_value) - typo_sass_vars = typography_map[field][prop_key] for typo_sass_var in typo_sass_vars: mapped[typo_sass_var] = prop_value @@ -560,53 +557,3 @@ def _html_dependencies(self) -> list[HTMLDependency]: return theme_deps return [fonts_dep, *theme_deps] - - -def maybe_convert_font_size_to_rem(x: str) -> str: - """ - Convert a font size to rem - - Bootstrap expects base font size to be in `rem`. This function converts `em`, `%`, - `px`, `pt` to `rem`: - - 1. `em` is directly replace with `rem`. - 2. `1%` is `0.01rem`, e.g. `90%` becomes `0.9rem`. - 3. `16px` is `1rem`, e.g. `18px` becomes `1.125rem`. - 4. `12pt` is `1rem`. - 5. `0.1666in` is `1rem`. - 6. `4.234cm` is `1rem`. - 7. `42.3mm` is `1rem`. - """ - x_og = f"{x}" - - value, unit = split_css_value_and_unit(x) - - if unit == "rem": - return x - - if unit == "em": - return f"{value}rem" - - scale = { - "%": 100, - "px": 16, - "pt": 12, - "in": 96 / 16, # 96 px/inch - "cm": 96 / 16 * 2.54, # inch -> cm - "mm": 16 / 96 * 25.4, # cm -> mm - } - - if unit in scale: - return f"{float(value) / scale[unit]}rem" - - raise ValueError( - f"Shiny does not support brand.yml font sizes in {unit} units ({x_og!r})" - ) - - -def split_css_value_and_unit(x: str) -> tuple[str, str]: - match = re.match(r"^(-?\d*\.?\d+)([a-zA-Z%]*)$", x) - if not match: - raise ValueError(f"Invalid CSS value format: {x}") - value, unit = match.groups() - return value, unit From 5d059e2129acd0ac67054b62f0b58909d6fafc83 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 09:29:47 -0400 Subject: [PATCH 69/82] refactor: simplify finding spec or pkg --- shiny/ui/_theme.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index a85872126..72f2d5181 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -568,10 +568,7 @@ def check_is_valid_preset(preset: str | None) -> ShinyThemePreset: def check_theme_pkg_installed(pkg: str, spec: str | None = None) -> None: import importlib.util - if spec is None: - spec = pkg - - if importlib.util.find_spec(spec) is None: + if importlib.util.find_spec(spec or pkg) is None: raise ImportError( f"The '{pkg}' package is required to compile custom themes. " 'Please install it with `pip install {pkg}` or `pip install "shiny[theme]"`.', From f2d35b695b72920d5c9fefaa613de9eb9bdf69a0 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 09:29:59 -0400 Subject: [PATCH 70/82] chore(examples): Add requirements.txt --- examples/brand/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 examples/brand/requirements.txt diff --git a/examples/brand/requirements.txt b/examples/brand/requirements.txt new file mode 100644 index 000000000..430504d8f --- /dev/null +++ b/examples/brand/requirements.txt @@ -0,0 +1,3 @@ +shiny[theme] +matplotlib +numpy From 83e4a3350092897cac41f23077d3c563b530c66e Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 09:46:37 -0400 Subject: [PATCH 71/82] docs: Fill out `.from_brand()` documentation --- examples/brand/app-express.py | 10 +++++++ shiny/ui/_theme.py | 56 +++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 examples/brand/app-express.py diff --git a/examples/brand/app-express.py b/examples/brand/app-express.py new file mode 100644 index 000000000..bbebddc3e --- /dev/null +++ b/examples/brand/app-express.py @@ -0,0 +1,10 @@ +from shiny.express import input, render, ui + +ui.page_opts(theme=ui.Theme.from_brand(__file__)) + +ui.input_slider("n", "N", 0, 100, 20) + + +@render.code +def txt(): + return f"n*2 is {input.n() * 2}" diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index 72f2d5181..6bfaaedc0 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -508,9 +508,59 @@ def from_brand(cls, brand: "str | pathlib.Path | Brand"): Create a custom Shiny theme from a `_brand.yml` Creates a custom Shiny theme for your brand using - [brand.yml](https://posit-dev.github.io/brand-yml), which may be either an - instance of :class:`brand_yml.Brand` or a :class:`Path` used by - :meth:`brand_yml.Brand.from_yaml` to locate the `_brand.yml` file. + [brand.yml](https://posit-dev.github.io/brand-yml), a single YAML file that + describes the brand's color and typography. Learn more about writing a + `_brand.yml` file for your Brand at the + [brand.yml homepage](https://posit-dev.github.io/brand-yml). + + As a simple example, suppose your brand guidelines include a color palette with + custom orange and black colors. The orange is used as the primary accent color + and the black for all text. For typography, the brand also uses + [Roboto](https://fonts.google.com/specimen/Roboto?query=roboto) and + [Roboto Mono](https://fonts.google.com/specimen/Roboto+Mono?query=roboto) from + Google Fonts for text and monospace-styled text, respectively. Here's a + `_brand.yml` file for this brand: + + ```{.yaml filename="_brand.yml"} + meta: + name: brand.yml Example + + color: + palette: + orange: "#F96302" + black: "#000000" + foreground: black + primary: orange + + typography: + fonts: + - family: Roboto + source: google + - family: Roboto Mono + source: google + base: Roboto + monospace: Roboto Mono + ``` + + You can store the `_brand.yml` file next to your Shiny `app.py` or, for larger + projects, in a parent folder. To use a theme generated from the `_brand.yml` + file, call :meth:`~shiny.ui.Theme.from_brand` on `__file__` and pass the result + to the `theme` argument of :func:`~shiny.express.ui.page_opts` (Shiny Express) + or the `theme` argument of `shiny.ui.page_*` functions, like + :func:`~shiny.ui.page_sidebar`. + + ```{.python filename="app.py"} + from shiny.express import input, render, ui + + ui.page_opts(theme=ui.Theme.from_brand(__file__)) + + ui.input_slider("n", "N", 0, 100, 20) + + + @render.code + def txt(): + return f"n*2 is {input.n() * 2}" + ``` Parameters ---------- From a34e9369ba022fff3e16efb995c2d116e04668df Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 09:47:12 -0400 Subject: [PATCH 72/82] docs: small edit --- shiny/ui/_theme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index 6bfaaedc0..780ed0adb 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -510,7 +510,7 @@ def from_brand(cls, brand: "str | pathlib.Path | Brand"): Creates a custom Shiny theme for your brand using [brand.yml](https://posit-dev.github.io/brand-yml), a single YAML file that describes the brand's color and typography. Learn more about writing a - `_brand.yml` file for your Brand at the + `_brand.yml` file for your brand at the [brand.yml homepage](https://posit-dev.github.io/brand-yml). As a simple example, suppose your brand guidelines include a color palette with From 9c9b0dacaeb0654416360ba687ced59b960ed402 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 11:45:53 -0400 Subject: [PATCH 73/82] chore: slim type --- shiny/ui/_theme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shiny/ui/_theme.py b/shiny/ui/_theme.py index 780ed0adb..cba938882 100644 --- a/shiny/ui/_theme.py +++ b/shiny/ui/_theme.py @@ -605,7 +605,7 @@ def path_pkg_preset(preset: ShinyThemePreset, *args: str) -> str: return pathlib.Path(path).as_posix() -def check_is_valid_preset(preset: str | None) -> ShinyThemePreset: +def check_is_valid_preset(preset: str) -> ShinyThemePreset: if preset not in shiny_theme_presets: raise ValueError( f"Invalid preset '{preset}'.\n" From 439f5578ad18ebd46e0539b98d20f40b77a0fbf9 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 12:18:22 -0400 Subject: [PATCH 74/82] refactor: BrandBootstrapConfig --- examples/brand/_brand.yml | 3 +- shiny/ui/_theme_brand.py | 83 +++++++++++++++++---------------------- 2 files changed, 39 insertions(+), 47 deletions(-) diff --git a/examples/brand/_brand.yml b/examples/brand/_brand.yml index dbdba16f1..577d62aa6 100644 --- a/examples/brand/_brand.yml +++ b/examples/brand/_brand.yml @@ -104,7 +104,8 @@ typography: defaults: bootstrap: - my-pink: "$brand-pink" + defaults: + my-pink: "$brand-pink" shiny: theme: preset: shiny diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index ea12360e1..7dcc3a60e 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -122,6 +122,8 @@ def warn_or_raise_unmapped_variable(unmapped: str): class BrandBootstrapConfigFromYaml: + """Validate a Bootstrap config from a YAML source""" + def __init__( self, path: str, @@ -133,7 +135,8 @@ def __init__( rules: Any = None, ): - self.path = path + # TODO: Remove `path` and handle in try/except block in caller + self._path = path self.version = version self.preset: str | None = self._validate_str(preset, "preset") self.functions: str | None = self._validate_str(functions, "functions") @@ -148,29 +151,31 @@ def _validate_str(self, x: Any, param: str) -> str | None: return x raise ValueError( - f"Invalid brand `{self.path}.{param}`. Must be a string or empty." + f"Invalid brand `{self._path}.{param}`. Must be a string or empty." ) def _validate_defaults(self, x: Any) -> dict[str, YamlScalarType] | None: if x is None: return None - path = self.path - if path == "defaults.shiny.theme": - path += ".defaults" - if not isinstance(x, dict): - raise ValueError(f"Invalid brand `{path}`, must be a dictionary.") + raise ValueError( + f"Invalid brand `{self._path}.defaults`, must be a dictionary." + ) y: dict[Any, Any] = x if not all([isinstance(k, str) for k in y.keys()]): - raise ValueError(f"Invalid brand `{path}`, all keys must be strings.") + raise ValueError( + f"Invalid brand `{self._path}.defaults`, all keys must be strings." + ) if not all( [v is None or isinstance(v, (str, int, float, bool)) for v in y.values()] ): - raise ValueError(f"Invalid brand `{path}`, all values must be scalar.") + raise ValueError( + f"Invalid brand `{self._path}.defaults`, all values must be scalar." + ) res: dict[str, YamlScalarType] = y return res @@ -217,52 +222,38 @@ def from_brand(cls, brand: Brand): if not brand.defaults: return cls(version=v_bootstrap, preset="shiny") - defaults: dict[str, YamlScalarType] = {} - - d_bootstrap = cls._brand_defaults_bootstrap(brand) - d_shiny = cls._brand_defaults_shiny(brand) - - defaults.update(d_bootstrap.defaults or {}) - defaults.update(d_shiny.defaults or {}) + shiny_args = {} + if "shiny" in brand.defaults and "theme" in brand.defaults["shiny"]: + shiny_args = brand.defaults["shiny"]["theme"] - return cls( - version=d_shiny.version or d_bootstrap.version or v_bootstrap, - preset=d_shiny.preset or d_bootstrap.preset, - functions=d_shiny.functions, - defaults=defaults, - mixins=d_shiny.mixins, - rules=d_shiny.rules, + shiny = BrandBootstrapConfigFromYaml( + path="defaults.shiny.theme", + **shiny_args, ) - @staticmethod - def _brand_defaults_shiny(brand: Brand) -> BrandBootstrapConfigFromYaml: - if ( - not brand.defaults - or not isinstance(brand.defaults.get("shiny"), dict) - or not isinstance(brand.defaults["shiny"].get("theme"), dict) - ): - return BrandBootstrapConfigFromYaml(path="defaults.shiny.theme") + bs_args = {} + if "bootstrap" in brand.defaults: + bs_args = brand.defaults["bootstrap"] - return BrandBootstrapConfigFromYaml( - path="defaults.shiny.theme", - **brand.defaults["shiny"]["theme"], + bootstrap = BrandBootstrapConfigFromYaml( + path="defaults.bootstrap", + **bs_args, ) - @staticmethod - def _brand_defaults_bootstrap(brand: Brand) -> BrandBootstrapConfigFromYaml: - if not brand.defaults or not isinstance(brand.defaults.get("bootstrap"), dict): - return BrandBootstrapConfigFromYaml(path="defaults.bootstrap") + # now combine bootstrap and shiny config options in a way that makes sense + def join_str(x: str | None, y: str | None): + return "\n".join([z for z in [x, y] if z is not None]) - bootstrap: dict[str, Any] = brand.defaults["bootstrap"] - defaults: dict[str, Any] = { - k: v for k, v in bootstrap.items() if k not in ("version", "preset") - } + defaults = bootstrap.defaults or {} + defaults.update(shiny.defaults or {}) - return BrandBootstrapConfigFromYaml( - path="defaults.bootstrap", - version=bootstrap.get("version"), - preset=bootstrap.get("preset"), + return cls( + version=shiny.version or bootstrap.version or v_bootstrap, + preset=shiny.preset or bootstrap.preset, + functions=join_str(bootstrap.functions, shiny.functions), defaults=defaults, + mixins=join_str(bootstrap.mixins, shiny.mixins), + rules=join_str(bootstrap.rules, shiny.rules), ) From 805aecd1e65e278c7cb33c432c9dfdc134f1b91d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 12:20:54 -0400 Subject: [PATCH 75/82] chore: Add changelog item --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b7b9a77..df60feb61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `chat_ui()` and `Chat.ui()` gain a `messages` parameter for providing starting messages. (#1736) +* Shiny now supports theming via [brand.yml](https://posit-dev.github.io/brand-yml) with a single `_brand.yml` file. Call `ui.Theme.from_brand()` with `__file__` or the path to a `_brand.yml` file and pass the resulting theme to the `theme` argument of `express.ui.page_opts()` (Shiny Express) or `ui.page_*()` functions (Shiny Core) to apply the brand theme to the entire app. (#1743) + ### Other changes * Incorporated `orjson` for faster data serialization in `@render.data_frame` outputs. (#1570) From 65ae093bcc6bfc64c09559f30cbabfe0e4749056 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 12:26:24 -0400 Subject: [PATCH 76/82] chore: use released brand_yml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 950e99821..29b5d8fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ [project.optional-dependencies] theme = [ "libsass>=0.23.0", - "brand_yml@git+https://github.com/posit-dev/brand-yml" + "brand_yml>=0.1.0" ] test = [ "pytest>=6.2.4", @@ -103,7 +103,7 @@ dev = [ "Flake8-pyproject>=1.2.3", "isort>=5.10.1", "libsass>=0.23.0", - "brand_yml>=0.1.0rc5", + "brand_yml>=0.1.0", "pyright>=1.1.383", "pre-commit>=2.15.0", "wheel", From bda50541a3c98b4ac6f002409c8d6a91300b3214 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 12:28:55 -0400 Subject: [PATCH 77/82] chore: create new dict --- shiny/ui/_theme_brand.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 7dcc3a60e..7902b0521 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -244,7 +244,8 @@ def from_brand(cls, brand: Brand): def join_str(x: str | None, y: str | None): return "\n".join([z for z in [x, y] if z is not None]) - defaults = bootstrap.defaults or {} + defaults: dict[str, YamlScalarType] = {} + defaults.update(bootstrap.defaults or {}) defaults.update(shiny.defaults or {}) return cls( From 9b7314744f4e83f3e16298d925274f94c89d8b2d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 12:30:36 -0400 Subject: [PATCH 78/82] chore: only import brand for type checking --- shiny/ui/_theme_brand.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/shiny/ui/_theme_brand.py b/shiny/ui/_theme_brand.py index 7902b0521..2ea0df0c0 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -3,9 +3,10 @@ import os import warnings from pathlib import Path -from typing import Any, Optional, Union +from typing import TYPE_CHECKING, Any, Optional, Union -from brand_yml import Brand +if TYPE_CHECKING: + from brand_yml import Brand from htmltools import HTMLDependency from .._versions import bootstrap as v_bootstrap @@ -218,7 +219,7 @@ def __init__( self.rules = rules @classmethod - def from_brand(cls, brand: Brand): + def from_brand(cls, brand: "Brand"): if not brand.defaults: return cls(version=v_bootstrap, preset="shiny") @@ -261,7 +262,7 @@ def join_str(x: str | None, y: str | None): class ThemeBrand(Theme): def __init__( self, - brand: Brand, + brand: "Brand", *, include_paths: Optional[str | Path | list[str | Path]] = None, ): @@ -287,12 +288,12 @@ def __init__( # 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 + # 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() @@ -303,12 +304,12 @@ def __init__( 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) + # "Brand" rules (now in forwards order) self._add_rules_brand_colors(css_vars_brand) self._add_sass_brand_rules() self._add_brand_bootstrap_other(brand_bootstrap) - def _get_theme_name(self, brand: Brand) -> str: + def _get_theme_name(self, brand: "Brand") -> str: if not brand.meta or not brand.meta.name: return "brand" @@ -316,7 +317,7 @@ def _get_theme_name(self, brand: Brand) -> str: @staticmethod def _prepare_color_vars( - brand: Brand, + 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""" if not brand.color: @@ -348,12 +349,12 @@ def _prepare_color_vars( # => 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 + # 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 @staticmethod - def _prepare_typography_vars(brand: Brand) -> dict[str, str]: + def _prepare_typography_vars(brand: "Brand") -> dict[str, str]: """Typography: Create a list of Bootstrap Sass variables""" mapped: dict[str, str] = {} @@ -474,7 +475,7 @@ def _add_sass_brand_grays(self): ) def _add_sass_brand_rules(self): - """Additional rules to fill in Bootstrap styles for Brand parameters""" + """Additional rules to fill in Bootstrap styles for "Brand" parameters""" self.add_rules( """ // *---- brand: brand rules to augment Bootstrap rules ----* // From 75dcf2ae6fcc665370fa9df2bb3b0ef5393db839 Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Fri, 25 Oct 2024 11:33:57 -0500 Subject: [PATCH 79/82] Update CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df60feb61..68dafcce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,10 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added [narwhals](https://posit-dev.github.io/py-narwhals) support for `@render.table`. This allows for any eager data frame supported by narwhals to be returned from a `@render.table` output method. (#1570) -* `chat_ui()` and `Chat.ui()` gain a `messages` parameter for providing starting messages. (#1736) - * Shiny now supports theming via [brand.yml](https://posit-dev.github.io/brand-yml) with a single `_brand.yml` file. Call `ui.Theme.from_brand()` with `__file__` or the path to a `_brand.yml` file and pass the resulting theme to the `theme` argument of `express.ui.page_opts()` (Shiny Express) or `ui.page_*()` functions (Shiny Core) to apply the brand theme to the entire app. (#1743) +* `chat_ui()` and `Chat.ui()` gain a `messages` parameter for providing starting messages. (#1736) + ### Other changes * Incorporated `orjson` for faster data serialization in `@render.data_frame` outputs. (#1570) From 51dc33b600798788c96863faf5208c040e866c4c Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Fri, 25 Oct 2024 12:50:29 -0400 Subject: [PATCH 80/82] Update _theme_brand.py Co-authored-by: Carson Sievert --- 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 2ea0df0c0..5713feb34 100644 --- a/shiny/ui/_theme_brand.py +++ b/shiny/ui/_theme_brand.py @@ -221,7 +221,7 @@ def __init__( @classmethod def from_brand(cls, brand: "Brand"): if not brand.defaults: - return cls(version=v_bootstrap, preset="shiny") + return cls() shiny_args = {} if "shiny" in brand.defaults and "theme" in brand.defaults["shiny"]: From 339a9aa147d4b1a4a7e23bfd05c422bae0162378 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 25 Oct 2024 13:24:54 -0500 Subject: [PATCH 81/82] example: use layout_sidebar() not page_sidebar() --- examples/brand/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/brand/app.py b/examples/brand/app.py index 59732b58c..52ac58f2d 100644 --- a/examples/brand/app.py +++ b/examples/brand/app.py @@ -16,7 +16,7 @@ app_ui = ui.page_navbar( ui.nav_panel( "Input Output Demo", - ui.page_sidebar( + ui.layout_sidebar( ui.sidebar( ui.input_slider("slider1", "Numeric Slider Input", 0, 11, 11), ui.input_numeric("numeric1", "Numeric Input Widget", 30), From edbae14b1b16a60136efa77c06f8425688c7bc26 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 25 Oct 2024 13:25:41 -0500 Subject: [PATCH 82/82] example: pass brand colors along to plotting code (instead of repeating them) --- examples/brand/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/brand/app.py b/examples/brand/app.py index 52ac58f2d..1cdbb8e9b 100644 --- a/examples/brand/app.py +++ b/examples/brand/app.py @@ -252,9 +252,9 @@ def server(input, output, session): @render.plot def plot1(): colors = { - "foreground": "#000000", - "background": "#FFFFFF", - "primary": "#4463ff", + "foreground": theme.brand.color.foreground, + "background": theme.brand.color.background, + "primary": theme.brand.color.primary, } if theme.brand.color: