Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
f0b3464
feat: initial BrandTheme
gadenbuie Oct 21, 2024
730af5c
feat: rename ThemeBrand
gadenbuie Oct 21, 2024
b73470f
feat: Add `_brand*.yml` to default reload includes
gadenbuie Oct 21, 2024
ef936ea
feat(Theme): Add `from_brand()` method
gadenbuie Oct 22, 2024
9ebf4a5
docs: Add brand-yml to interlinks inventories
gadenbuie Oct 22, 2024
9fe9714
chore: add brand_yml to dev dependencies too
gadenbuie Oct 22, 2024
3943ebc
chore(Theme): Rename method `_html_dependencies`
gadenbuie Oct 22, 2024
669e08a
feat: get fonts dependency from brand.typography
gadenbuie Oct 23, 2024
263ee07
chore: clean up code
gadenbuie Oct 23, 2024
6035788
feat: Apply brand.color.palette to bootstrap color map
gadenbuie Oct 23, 2024
3184691
fix: Use Bootstrap major version
gadenbuie Oct 23, 2024
137cc4c
chore: Add todo comments
gadenbuie Oct 23, 2024
949f6f1
refactor: Move ThemeBrand init code into constructor
gadenbuie Oct 23, 2024
2af0941
feat(ThemeBrand): sanitize color name into valid sass/css variable names
gadenbuie Oct 23, 2024
03adf5c
refactor: Use `brand.color.to_dict()` method
gadenbuie Oct 23, 2024
ced644a
chore: Apply suggestions from code review
gadenbuie Oct 23, 2024
409a53b
refactor: More strongly type sass var maps
gadenbuie Oct 23, 2024
dcf4e32
refactor: Don't version the bootstrap color list
gadenbuie Oct 23, 2024
948ddce
refactor: Consolidate all brand.color sass var logic
gadenbuie Oct 23, 2024
142409e
refactor: Raise ThemeBrandUnmappedFieldError following envvar
gadenbuie Oct 23, 2024
048622e
chore: Update brand.ya?ml reload includes
gadenbuie Oct 23, 2024
96a9b95
Merged origin/main into feat/brand-yml
gadenbuie Oct 23, 2024
fc66b21
feat: Convert `typography.base.size` to `rem` for Bootstrap
gadenbuie Oct 23, 2024
20b1210
feat(brand): Add example app and brand
gadenbuie Oct 23, 2024
b905415
feat(brand): finish mapping the variables
gadenbuie Oct 23, 2024
95ffd5a
chore: Add some notes in example brand
gadenbuie Oct 23, 2024
db42401
chore: add a few more notes
gadenbuie Oct 23, 2024
f785d75
refactor: Simplify splitting css value and unit
gadenbuie Oct 24, 2024
aaed92a
chore: link back to bootstrap source for code rules
gadenbuie Oct 24, 2024
a012b8a
fix(brand): Map typography.link.color to $link-color-dark too
gadenbuie Oct 24, 2024
a9fb968
feat(example): Add colors page
gadenbuie Oct 24, 2024
5624064
fix(typing): of split_css_value_and_unit()
gadenbuie Oct 24, 2024
67e9b7d
feat(brand): Swap foreground/background in dark mode
gadenbuie Oct 24, 2024
c772164
feat(brand): Pick white/black from brand's foreground/background and …
gadenbuie Oct 24, 2024
2573e0a
feat(brand-example): Add color swatch page
gadenbuie Oct 24, 2024
e05ab3d
chore: remove sass debug output
gadenbuie Oct 24, 2024
484c89b
feat(brand): restore card borders in dark mode if brand makes dark mode
gadenbuie Oct 24, 2024
51cf4f1
feat(brand): Mix of font sources
gadenbuie Oct 24, 2024
75ab45d
fix(brand): Rules for code-block-line-height
gadenbuie Oct 24, 2024
792af38
refactor: ._handle_unmapped_variable method
gadenbuie Oct 24, 2024
c5f6302
refactor(ThemeBrand): Separate into smaller methods
gadenbuie Oct 24, 2024
0fe7fa1
chore(brand): rename methods and add sass comments for dividers
gadenbuie Oct 24, 2024
b1d0ccc
refactor: move theme method calls into helper methods
gadenbuie Oct 24, 2024
a4eee03
refactor: factor out get_theme_name
gadenbuie Oct 24, 2024
ab5085e
refactor: Rename BrandBootstrapConfig
gadenbuie Oct 24, 2024
75a7f97
feat: read layers from `brand.defaults.shiny.theme.*`
gadenbuie Oct 24, 2024
438605f
chore: Add `!default` flag
gadenbuie Oct 24, 2024
f083519
chore: early return in no-op case
gadenbuie Oct 24, 2024
dbc4759
refactor: Static method for brand to sass variable helpers
gadenbuie Oct 24, 2024
6e57945
fix: brand-yml inventory objects location
gadenbuie Oct 24, 2024
1c2a504
feat(typography): Map inline code color to $code-color-dark
gadenbuie Oct 24, 2024
9591fb2
example(brand): Keep navbar visible
gadenbuie Oct 24, 2024
721b1be
feat: check that brand_yml is installed before using
gadenbuie Oct 24, 2024
c314806
refactor(BrandBootstrapConfig): Make static methods
gadenbuie Oct 24, 2024
cf103d8
refactor: return brand/bootstrap color sass vars separately
gadenbuie Oct 24, 2024
82b4493
chore: set black/white to brand black white if not set
gadenbuie Oct 24, 2024
59b93c9
refactor: simplify return value
gadenbuie Oct 24, 2024
9e8211c
chore(types): Fix return value
gadenbuie Oct 24, 2024
22bd9bd
refactor: Move preset checking into `ui.Theme()`
gadenbuie Oct 24, 2024
1242c6d
fix: defaults from `defaults.bootstrap`
gadenbuie Oct 24, 2024
9bebe72
fix: rework layer adding to ensure correct order
gadenbuie Oct 24, 2024
0d72daa
chore: update type hint
gadenbuie Oct 24, 2024
fa78936
temp: switch to brand_yml from github
gadenbuie Oct 25, 2024
f7f5605
refactor: brand_yml now handles validation of `color.palette` names
gadenbuie Oct 25, 2024
16db15d
chore: use `_add_defaults_hdr()` in additional place
gadenbuie Oct 25, 2024
af8532a
refactor: don't use `as_css_unit()` just work with strings
gadenbuie Oct 25, 2024
04b3011
refactor: Call `cls` from inside class method
gadenbuie Oct 25, 2024
d030f8d
chore(reload): On `yml` and `yaml` changes
gadenbuie Oct 25, 2024
90b03f5
refactor: brand_yml handles converting typography.base.size to rem
gadenbuie Oct 25, 2024
5d059e2
refactor: simplify finding spec or pkg
gadenbuie Oct 25, 2024
f2d35b6
chore(examples): Add requirements.txt
gadenbuie Oct 25, 2024
83e4a33
docs: Fill out `.from_brand()` documentation
gadenbuie Oct 25, 2024
a34e936
docs: small edit
gadenbuie Oct 25, 2024
9c9b0da
chore: slim type
gadenbuie Oct 25, 2024
439f557
refactor: BrandBootstrapConfig
gadenbuie Oct 25, 2024
805aecd
chore: Add changelog item
gadenbuie Oct 25, 2024
65ae093
chore: use released brand_yml
gadenbuie Oct 25, 2024
bda5054
chore: create new dict
gadenbuie Oct 25, 2024
9b73147
chore: only import brand for type checking
gadenbuie Oct 25, 2024
75dcf2a
Update CHANGELOG.md
cpsievert Oct 25, 2024
51dc33b
Update _theme_brand.py
gadenbuie Oct 25, 2024
339a9aa
example: use layout_sidebar() not page_sidebar()
cpsievert Oct 25, 2024
edbae14
example: pass brand colors along to plotting code (instead of repeati…
cpsievert Oct 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ dependencies = [
]

[project.optional-dependencies]
theme = ["libsass>=0.23.0"]
theme = [
"libsass>=0.23.0",
"brand_yml>=0.1.0rc5"
]
test = [
"pytest>=6.2.4",
"pytest-asyncio>=0.17.2",
Expand Down Expand Up @@ -100,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",
Expand Down
1 change: 1 addition & 0 deletions shiny/_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def main() -> None:
"*.htm",
"*.html",
"*.png",
"_brand*.yml",
)
RELOAD_EXCLUDES_DEFAULT = (".*", "*.py[cod]", "__pycache__", "env", "venv")

Expand Down
25 changes: 18 additions & 7 deletions shiny/ui/_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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:
"""
Expand Down
297 changes: 297 additions & 0 deletions shiny/ui/_theme_brand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
from __future__ import annotations

import warnings
from pathlib import Path
from shutil import copyfile
from typing import Any, Optional

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": {
"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",
},
}
"""Maps brand.typography fields to corresponding Bootstrap Sass variables"""


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}."
)

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}."
)

self.version: str = str(version)
self.preset: ShinyThemePreset = preset
self.defaults = kwargs

@classmethod
def from_brand(cls, brand: Brand):
defaults: dict[str, str | int | bool | float | None] = {}

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"])

return cls(**defaults)


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

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;
}
"""
)
)
Loading
Loading